From 3a5761823d517c43de224144266a204804416327 Mon Sep 17 00:00:00 2001 From: Patrick Chin Date: Sun, 15 Nov 2026 02:52:16 +0800 Subject: [PATCH 01/37] Guard poop-window to review navigation in native tests (#211) * Require semantic arrival after skipping poop windows * Keep retry tap bound to the source page label --------- Co-authored-by: Patrick Chin <1392122+patrickchin@users.noreply.github.com> --- .../helpers/conversational/to-reminders.yaml | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/.maestro/helpers/conversational/to-reminders.yaml b/.maestro/helpers/conversational/to-reminders.yaml index 740ef1e6..c4dbe7ad 100644 --- a/.maestro/helpers/conversational/to-reminders.yaml +++ b/.maestro/helpers/conversational/to-reminders.yaml @@ -33,9 +33,30 @@ name: Skip optional answers to reminder setup id: preview.poopTimes - assertNotVisible: id: preview.poopMealTime.0 -- waitForAnimationToEnd -- tapOn: - id: preview.skip +# A changed hierarchy did not prove arrival in the failed hosted run. Retry +# only this source/target transition, at most once plus two retries. +- retry: + maxRetries: 2 + commands: + - runFlow: + when: + visible: + id: preview.poopTimes + commands: + - waitForAnimationToEnd + - runFlow: + when: + notVisible: + id: preview.planTimeline + commands: + - assertVisible: + id: preview.poopTimes + - tapOn: + id: preview.skip + text: ^Skip for now$ + enabled: true + - assertVisible: + id: preview.planTimeline - assertVisible: id: preview.planTimeline - waitForAnimationToEnd From 0e54a54884da180dcfad238557c304ac5f3a3767 Mon Sep 17 00:00:00 2001 From: Patrick Chin Date: Sun, 15 Nov 2026 03:13:06 +0800 Subject: [PATCH 02/37] Fix disposable signing-proof SDK bootstrap and source pins (#213) * Fix signing-proof SDK bootstrap and pin equivalent driver inputs * Accept only the expected missing-package exit in signing QA --------- Co-authored-by: Patrick Chin <1392122+patrickchin@users.noreply.github.com> --- .github/workflows/android-signing-proof.yml | 22 ++++++++++-- scripts/verify-android-signing-upgrade.mjs | 38 ++++++++++++++++----- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/.github/workflows/android-signing-proof.yml b/.github/workflows/android-signing-proof.yml index 21c27bba..201bac27 100644 --- a/.github/workflows/android-signing-proof.yml +++ b/.github/workflows/android-signing-proof.yml @@ -3,8 +3,12 @@ run-name: NOT FOR RELEASE - Android signing proof on: workflow_dispatch: inputs: + expected_driver: + description: Exact reviewed 40-character SHA of this workflow and driver + type: string + required: true expected_source: - description: Exact reviewed 40-character source SHA shared by this driver and both artifact-only runs + description: Exact reviewed 40-character app source SHA shared by both artifact-only runs type: string required: true bridge_run_id: @@ -26,6 +30,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 45 env: + PUPTIME_EXPECTED_DRIVER: ${{ inputs.expected_driver }} PUPTIME_EXPECTED_SOURCE: ${{ inputs.expected_source }} PUPTIME_BRIDGE_RUN: ${{ inputs.bridge_run_id }} PUPTIME_LATER_RUN: ${{ inputs.later_run_id }} @@ -48,16 +53,29 @@ jobs: run: echo "PUPTIME_SIGNING_PRIVATE=$RUNNER_TEMP/puptime-signing-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" >> "$GITHUB_ENV" - name: Static source checks (no keys or device) run: node scripts/verify-android-signing-upgrade.mjs --self-check + - run: npm ci - name: Pin completed artifact-only runs and immutable APKs env: GH_TOKEN: ${{ github.token }} run: node scripts/verify-android-signing-upgrade.mjs --preflight - - run: npm ci - name: Prepare isolated Android tools shell: bash run: | set -euo pipefail umask 077 + test -n "$ANDROID_HOME" + puptime_sdkmanager=$(find "$ANDROID_HOME/cmdline-tools" -type f -name sdkmanager | sort -V | tail -n 1) + test -n "$puptime_sdkmanager" + puptime_cmdline_bin=$(dirname "$puptime_sdkmanager") + for puptime_tool in "$puptime_cmdline_bin/sdkmanager" "$puptime_cmdline_bin/avdmanager" "$ANDROID_HOME/platform-tools/adb" "$ANDROID_HOME/emulator/emulator"; do + test -x "$puptime_tool" + printf 'Android executable: %s\n' "$puptime_tool" + done + export PATH="$puptime_cmdline_bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH" + printf '%s\n' "$puptime_cmdline_bin" "$ANDROID_HOME/platform-tools" "$ANDROID_HOME/emulator" >> "$GITHUB_PATH" + timeout 30 sdkmanager --version | sed -n '1,4p' + timeout 30 adb version | sed -n '1,4p' + timeout 30 emulator -version | sed -n '1,4p' sdkmanager 'build-tools;36.0.0' 'system-images;android-36;google_apis;x86_64' export ANDROID_AVD_HOME="$PUPTIME_SIGNING_PRIVATE/avd" mkdir -m 700 "$ANDROID_AVD_HOME" diff --git a/scripts/verify-android-signing-upgrade.mjs b/scripts/verify-android-signing-upgrade.mjs index a3109825..3fcfd71b 100644 --- a/scripts/verify-android-signing-upgrade.mjs +++ b/scripts/verify-android-signing-upgrade.mjs @@ -13,6 +13,19 @@ const oldCert='fac61745dc0903786fb9ede62a962b399f7348f0bb6f899b8332667591033b9c' const keySHA='221e0a3106aa4c3ccc154e0a418b55020b3f9ea6e84f92e8749cd9e2f39f5e58'; const caps={'installed data':true,'shared UID':false,permission:false,rollback:false,auth:false}; const hash=value=>createHash('sha256').update(value).digest('hex'); +const toolingPaths=['.github/workflows/android-signing-proof.yml','scripts/verify-android-signing-upgrade.mjs']; +function validateDriver(expected,actual){assert.match(expected??'',/^[a-f0-9]{40}$/);assert.equal(actual,expected,'Pin the reviewed driver SHA separately from artifact source.');} +function equivalentChanges(raw){ + if(!raw)return []; + assert.ok(raw.endsWith('\0'));const fields=raw.slice(0,-1).split('\0');assert.equal(fields.length%2,0); + const changes=[]; + for(let i=0;ivalue.path===path));changes.push({path,mode:'100644',sourceBlob:match[1],driverBlob:match[2]}); + } + return changes; +} // These Android widget files/lock are identical in the original125 source and // the reviewed candidate. English accessibility labels do not follow app locale. // RNWidgetProvider persists only WIDGET_SIZES width/height; palette is rendered @@ -60,6 +73,11 @@ function widgetBinding(text){ } function selfCheck(){ assertWidgetSource(); + validateDriver('d'.repeat(40),'d'.repeat(40));assert.throws(()=>validateDriver('d'.repeat(40),'e'.repeat(40)));assert.throws(()=>validateDriver('short','short')); + const change=path=>`:100644 100644 ${'a'.repeat(40)} ${'b'.repeat(40)} M\0${path}\0`; + assert.equal(equivalentChanges(toolingPaths.map(change).join('')).length,2);assert.deepEqual(equivalentChanges(''),[]); + for(const path of ['App.tsx','package-lock.json','plugins/native.js','server/auth.ts','.github/workflows/android-apk.yml'])assert.throws(()=>equivalentChanges(change(path))); + for(const raw of [change(toolingPaths[0]).replace('100644','100755'),change(toolingPaths[0]).replace(' M\0',' D\0'),change(toolingPaths[0]).slice(0,-1)])assert.throws(()=>equivalentChanges(raw)); const repo='example/qa',source='a'.repeat(40),run={id:1,repository:{full_name:repo},head_repository:{full_name:repo},head_sha:source,path:'.github/workflows/android-apk.yml',event:'workflow_dispatch',status:'completed',conclusion:'success'},jobs=[{name:'build',conclusion:'success'},{name:'publish',conclusion:'skipped'}]; validateRun(run,jobs,repo,source,'1');for(const patch of [{head_sha:'b'.repeat(40)},{event:'push'},{repository:{full_name:'other/repo'}},{path:'different.yml'},{conclusion:'failure'}])assert.throws(()=>validateRun({...run,...patch},jobs,repo,source,'1')); assert.throws(()=>validateRun(run,[jobs[0],{name:'publish',conclusion:'success'}],repo,source,'1')); @@ -78,7 +96,7 @@ function selfCheck(){ const [mode]=process.argv.slice(2);if(mode==='--self-check'){selfCheck();process.exit(0);} assert.ok(['--preflight','--exercise'].includes(mode));assert.equal(process.env.GITHUB_ACTIONS,'true');assert.equal(process.env.RUNNER_OS,'Linux');process.umask(0o077); const root=resolve(process.env.PUPTIME_SIGNING_PRIVATE??'');assert.ok(process.env.RUNNER_TEMP&&root.startsWith(resolve(process.env.RUNNER_TEMP)+'/puptime-signing-')); -const source=process.env.PUPTIME_EXPECTED_SOURCE;assert.match(source??'',/^[a-f0-9]{40}$/);assert.equal(process.env.GITHUB_SHA,source,'Freeze driver and artifact source together.');assertWidgetSource(); +const source=process.env.PUPTIME_EXPECTED_SOURCE,driverSource=process.env.PUPTIME_EXPECTED_DRIVER;assert.match(source??'',/^[a-f0-9]{40}$/);validateDriver(driverSource,process.env.GITHUB_SHA);assertWidgetSource(); const repo=process.env.GITHUB_REPOSITORY;assert.equal(repo,'patrickchin/puptime'); function command(binary,args,{ok=[0],binaryOutput=false,timeout=120000}={}){ const result=spawnSync(binary,args,{encoding:binaryOutput?undefined:'utf8',timeout,maxBuffer:256*1024*1024,stdio:['ignore','pipe','pipe']}); @@ -88,6 +106,11 @@ function command(binary,args,{ok=[0],binaryOutput=false,timeout=120000}={}){ } return binaryOutput?result.stdout:String(result.stdout??'')+(result.status?String(result.stderr??''):''); } +assert.equal(command('git',['rev-parse','HEAD']).trim(),driverSource); +assert.equal(command('git',['status','--porcelain','--untracked-files=no']).trim(),'','Require an unchanged tracked checkout.'); +if(spawnSync('git',['cat-file','-e',source+'^{commit}'],{stdio:'ignore',timeout:30000}).status!==0)command('git',['fetch','--no-tags','--depth=1','origin',source]); +const sourceEquivalence={source,driverSource,sourceTree:command('git',['rev-parse',source+'^{tree}']).trim(),driverTree:command('git',['rev-parse',driverSource+'^{tree}']).trim(),changes:equivalentChanges(command('git',['diff-tree','--no-commit-id','--raw','--no-abbrev','--no-renames','-r','-z',source,driverSource])),allOtherBlobsAndModesEqual:true}; +const output=resolve('build/android-signing-safe'); const api=path=>JSON.parse(command('gh',['api',`repos/${repo}/${path}`])); async function download(url,path,digest,token=false){ let response=await fetch(url,{redirect:'manual',headers:token?{Authorization:'Bearer '+process.env.GH_TOKEN}:{},signal:AbortSignal.timeout(120000)}); @@ -101,7 +124,7 @@ const marker=join(root,'NOT-FOR-RELEASE'); if(mode==='--preflight'){ assert.ok(!existsSync(root),'Fresh private directory required.');mkdirSync(root,{mode:0o700});writeFileSync(marker,source); const ids=[process.env.PUPTIME_BRIDGE_RUN,process.env.PUPTIME_LATER_RUN];assert.ok(ids.every(id=>/^[1-9]\d+$/.test(id??'')));assert.notEqual(...ids); - const inputs={source,baselineSHA:oldSHA,artifacts:[]}; + const inputs={source,driverSource,sourceEquivalence,baselineSHA:oldSHA,artifacts:[]}; for(const [index,id] of ids.entries()){ const run=api('actions/runs/'+id),jobs=api('actions/runs/'+id+'/jobs?per_page=100');assert.equal(jobs.total_count,jobs.jobs.length);validateRun(run,jobs.jobs,repo,source,id); const list=api('actions/runs/'+id+'/artifacts?per_page=100');assert.equal(list.total_count,list.artifacts.length); @@ -116,13 +139,13 @@ if(mode==='--preflight'){ inputs.artifacts.push({run:Number(id),...info,sha256:digest}); } await download(`https://github.com/${repo}/releases/download/v1.12.5/Puptime-1.12.5.apk`,join(root,'original125.apk'),oldSHA); - writeFileSync(join(root,'inputs.json'),JSON.stringify(inputs,null,2));console.log('PASS pinned artifact-only runs, ZIP/APK digests and exact source/version metadata. No signing or Android runtime.');process.exit(0); + writeFileSync(join(root,'inputs.json'),JSON.stringify(inputs,null,2));mkdirSync(output,{recursive:true,mode:0o700});writeFileSync(join(output,'verification.json'),JSON.stringify({status:'preflight-passed',source,driverSource,sourceEquivalence,artifacts:inputs.artifacts,phases:[],scope:'Pinned equivalent driver/app inputs only; no keys or Android runtime.'},null,2));console.log('PASS pinned driver/input tree equivalence, artifact-only runs, ZIP/APK digests and exact source/version metadata. No signing or Android runtime.');process.exit(0); } assert.equal(readFileSync(marker,'utf8'),source);assert.equal(lstatSync(root).mode&0o777,0o700); -const inputs=JSON.parse(readFileSync(join(root,'inputs.json')));assert.equal(inputs.source,source);assert.equal(await fileHash(join(root,'original125.apk')),oldSHA); +const inputs=JSON.parse(readFileSync(join(root,'inputs.json')));assert.equal(inputs.source,source);assert.equal(inputs.driverSource,driverSource);assert.deepEqual(inputs.sourceEquivalence,sourceEquivalence);assert.equal(await fileHash(join(root,'original125.apk')),oldSHA); for(const [i,name] of ['bridge','later'].entries())assert.equal(await fileHash(join(root,name,'input.apk')),inputs.artifacts[i].sha256); const device='emulator-5588',adb=(...args)=>command('adb',['-s',device,...args]);assert.equal(adb('emu','avd','name').trim().split(/\r?\n/)[0],'Puptime_Signing_QA_'+process.env.GITHUB_RUN_ID);assert.equal(adb('shell','getprop','ro.build.version.sdk').trim(),'36'); -assert.equal(adb('shell','settings','get','global','airplane_mode_on').trim(),'1');assert.equal(adb('shell','id','-u').trim(),'0');assert.equal(adb('shell','pm','path',app).trim(),'','The task emulator must not contain any pre-existing Puptime installation.'); +assert.equal(adb('shell','settings','get','global','airplane_mode_on').trim(),'1');assert.equal(adb('shell','id','-u').trim(),'0');assert.equal(command('adb',['-s',device,'shell','pm','path',app],{ok:[1]}),'','The task emulator must not contain any pre-existing Puptime installation.'); assert.equal(adb('shell','settings','get','global','wifi_on').trim(),'0');assert.equal(adb('shell','settings','get','global','mobile_data').trim(),'0'); const tools=join(process.env.ANDROID_HOME,'build-tools/36.0.0'),signer=join(tools,'apksigner'),aapt=join(tools,'aapt2'); function artifact(path,code,cert){const verified=command(signer,['verify','--verbose','--print-certs',path]);assert.ok(verified.includes('certificate SHA-256 digest: '+cert));const manifest=command(aapt,['dump','badging',path]).split('\n').slice(0,3);assert.ok(manifest[0].includes(`name='${app}' versionCode='${code}'`));assert.equal(manifest[1],"minSdkVersion:'24'");assert.equal(manifest[2],"targetSdkVersion:'36'");} @@ -145,8 +168,7 @@ const bridgeVerification=command(signer,['verify','--verbose','--print-certs','- const {nativeSnapshot,assertNativeTransition}=await import('./native-account-evidence.mjs'); const {eventDataFromSql}=await import('../src/record-sql.ts'); -const output=resolve('build/android-signing-safe');mkdirSync(output,{recursive:true,mode:0o700}); -const phases=[];function pass(phase,state){phases.push({phase,protectedStateSHA256:hash(JSON.stringify(state))});writeFileSync(join(output,'verification.json'),JSON.stringify({status:'in-progress',source,artifacts:inputs.artifacts,phases,scope:'Guest-only API36 x86; no credential or Play acceptance.'},null,2));console.log('PASS signing QA phase: '+phase);} +const phases=[];function pass(phase,state){phases.push({phase,protectedStateSHA256:hash(JSON.stringify(state))});writeFileSync(join(output,'verification.json'),JSON.stringify({status:'in-progress',source,driverSource,sourceEquivalence,artifacts:inputs.artifacts,phases,scope:'Guest-only API36 x86; no credential or Play acceptance.'},null,2));console.log('PASS signing QA phase: '+phase);} function flow(name,steps){const path=join(root,name+'.yaml');writeFileSync(path,`appId: ${app}\n---\n${steps}\n`);command('maestro',['--device',device,'test',path,'--test-output-dir',join(root,name+'-driver')],{timeout:240000});} const tap=id=>`- tapOn: {id: ${id}}\n`,scroll=id=>`- scrollUntilVisible:\n element: {id: ${id}}\n direction: DOWN\n visibilityPercentage: 100\n centerElement: true\n`; const settings=tap('nav.open')+'- assertVisible: {id: nav.close}\n'+tap('nav.settings')+'- assertVisible: {id: screen.settings}\n'; @@ -182,5 +204,5 @@ launch();flow('bridge-cold','- extendedWaitUntil:\n visible: {id: screen.toda const added=widget.tables.events.filter(row=>!bridge.tables.events.some(old=>old.id===row.id));assert.equal(added.length,1);assert.equal(added[0].type,'pee');assert.equal(added[0].source,'widget');const revisions=widget.tables.local_revisions.slice(bridge.tables.local_revisions.length);assert.equal(revisions.length,1);assert.equal(revisions[0].local_id,added[0].id);assert.equal(revisions[0].kind,'event');assert.equal(revisions[0].sequence,Math.max(0,...bridge.tables.local_revisions.map(row=>row.sequence))+1);assert.equal(revisions[0].pet_id,added[0].pet_id);assert.equal(revisions[0].source,'local');assert.ok(Number.isSafeInteger(revisions[0].created_at)&&revisions[0].created_at>=added[0].at);assert.equal(revisions[0].before_data,null);assert.deepEqual(JSON.parse(revisions[0].after_data),eventDataFromSql(added[0])); assertNativeTransition(bridge.tables,widget.tables,{events:[...bridge.tables.events,added[0]],local_revisions:[...bridge.tables.local_revisions,revisions[0]],sqlite_sequence:bridge.tables.sqlite_sequence.map(row=>row.name==='local_revisions'?{...row,seq:row.seq+1}:row)});assert.equal(widget.uid,bridge.uid);assert.deepEqual(widget.widgets,bridge.widgets);protectedFiles(bridge.files,widget.files,'1.12.6/1012006',bridge.tables.events,eventDataFromSql,bridge.tables.puppies[0].name);pass('bridge-and-existing-widget-save',widget); rejectUpdate(join(root,'later/input.apk'),widget,'higher-A-rollback-rejected');await install(join(root,'plain7.apk'),true);equal(widget,snapshot('later-before-launch'));launch();flow('later-visible','- extendedWaitUntil:\n visible: {id: screen.today}\n timeout: 90000\n');const later=snapshot('later-launched');launched(widget,later,'1.12.7/1012007');launch();flow('later-cold','- extendedWaitUntil:\n visible: {id: screen.today}\n timeout: 90000\n');equal(later,snapshot('later-cold'));pass('later-B-update-and-cold-restart',later); -writeFileSync(join(output,'verification.json'),JSON.stringify({status:'passed',source,artifacts:inputs.artifacts,phases,capabilities:caps,oldCertificate:oldCert,qaCertificate:b,scope:'Untouched125 guest→numeric QA bridge→numeric B update, API36 x86 only. No nonempty SecureStore/auth, genuine provider, ARM/OEM, Play or production-key acceptance.'},null,2)); +writeFileSync(join(output,'verification.json'),JSON.stringify({status:'passed',source,driverSource,sourceEquivalence,artifacts:inputs.artifacts,phases,capabilities:caps,oldCertificate:oldCert,qaCertificate:b,scope:'Untouched125 guest→numeric QA bridge→numeric B update, API36 x86 only. No nonempty SecureStore/auth, genuine provider, ARM/OEM, Play or production-key acceptance.'},null,2)); for(const path of readdirSync(root,{recursive:true}))if(lstatSync(join(root,path)).isFile())chmodSync(join(root,path),0o600); From 16743df861048728985ba47d31b7b8a87676e698 Mon Sep 17 00:00:00 2001 From: Patrick Chin Date: Sun, 15 Nov 2026 03:30:57 +0800 Subject: [PATCH 03/37] Install emulator before the signing-proof executable guard (#214) Co-authored-by: Patrick Chin <1392122+patrickchin@users.noreply.github.com> --- .github/workflows/android-signing-proof.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android-signing-proof.yml b/.github/workflows/android-signing-proof.yml index 201bac27..f5ac7556 100644 --- a/.github/workflows/android-signing-proof.yml +++ b/.github/workflows/android-signing-proof.yml @@ -67,7 +67,7 @@ jobs: puptime_sdkmanager=$(find "$ANDROID_HOME/cmdline-tools" -type f -name sdkmanager | sort -V | tail -n 1) test -n "$puptime_sdkmanager" puptime_cmdline_bin=$(dirname "$puptime_sdkmanager") - for puptime_tool in "$puptime_cmdline_bin/sdkmanager" "$puptime_cmdline_bin/avdmanager" "$ANDROID_HOME/platform-tools/adb" "$ANDROID_HOME/emulator/emulator"; do + for puptime_tool in "$puptime_cmdline_bin/sdkmanager" "$puptime_cmdline_bin/avdmanager" "$ANDROID_HOME/platform-tools/adb"; do test -x "$puptime_tool" printf 'Android executable: %s\n' "$puptime_tool" done @@ -75,8 +75,10 @@ jobs: printf '%s\n' "$puptime_cmdline_bin" "$ANDROID_HOME/platform-tools" "$ANDROID_HOME/emulator" >> "$GITHUB_PATH" timeout 30 sdkmanager --version | sed -n '1,4p' timeout 30 adb version | sed -n '1,4p' + sdkmanager 'emulator' 'build-tools;36.0.0' 'system-images;android-36;google_apis;x86_64' + test -x "$ANDROID_HOME/emulator/emulator" + printf 'Android executable: %s\n' "$ANDROID_HOME/emulator/emulator" timeout 30 emulator -version | sed -n '1,4p' - sdkmanager 'build-tools;36.0.0' 'system-images;android-36;google_apis;x86_64' export ANDROID_AVD_HOME="$PUPTIME_SIGNING_PRIVATE/avd" mkdir -m 700 "$ANDROID_AVD_HOME" echo "ANDROID_AVD_HOME=$ANDROID_AVD_HOME" >> "$GITHUB_ENV" From dda39c8d465140b5ef386391295c92d2c8e7d348 Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 03:34:57 +0800 Subject: [PATCH 04/37] Prepare strict app notice capture and packaged resource checks --- .github/workflows/checks.yml | 1 + app.json | 6 + .../notices/third_party_notices.json | 0 docs/app-notices.md | 151 ++++++++++++++ scripts/app-notice-provenance.json | 24 +++ scripts/app-notices.mjs | 189 ++++++++++++++++++ scripts/app-notices.test.mjs | 119 +++++++++++ scripts/capture-app-notices.mjs | 119 +++++++++++ scripts/native-notice-inputs.mjs | 82 ++++++++ scripts/native-notice-inputs.test.mjs | 59 ++++++ scripts/notice-archive.py | 83 ++++++++ scripts/notices-runtime.init.gradle | 45 +++++ src/components/FontIconLicenses.tsx | 6 +- src/font-icon-notices.test.ts | 3 +- 14 files changed, 884 insertions(+), 3 deletions(-) rename src/font-icon-notices.json => assets/notices/third_party_notices.json (100%) create mode 100644 docs/app-notices.md create mode 100644 scripts/app-notice-provenance.json create mode 100644 scripts/app-notices.mjs create mode 100644 scripts/app-notices.test.mjs create mode 100644 scripts/capture-app-notices.mjs create mode 100644 scripts/native-notice-inputs.mjs create mode 100644 scripts/native-notice-inputs.test.mjs create mode 100644 scripts/notice-archive.py create mode 100644 scripts/notices-runtime.init.gradle diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index c269b49d..44d12c18 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -15,6 +15,7 @@ jobs: cache: npm - run: npm ci - run: node --test scripts/native-task-cleanup.test.mjs + - run: node --test scripts/app-notices.test.mjs scripts/native-notice-inputs.test.mjs - run: npm run typecheck - run: npm run typecheck:ios - run: npm test diff --git a/app.json b/app.json index e70f5b8e..38b68955 100644 --- a/app.json +++ b/app.json @@ -97,6 +97,12 @@ } ], "expo-font", + [ + "expo-asset", + { + "assets": ["./assets/notices/third_party_notices.json"] + } + ], "./plugins/withAndroidWorkManager", "expo-sharing", [ diff --git a/src/font-icon-notices.json b/assets/notices/third_party_notices.json similarity index 100% rename from src/font-icon-notices.json rename to assets/notices/third_party_notices.json diff --git a/docs/app-notices.md b/docs/app-notices.md new file mode 100644 index 00000000..4223fb30 --- /dev/null +++ b/docs/app-notices.md @@ -0,0 +1,151 @@ +# App notice capture and final-artifact gate + +This is incomplete build preparation. The source JSON currently contains the +reviewed font/icon notices only. An empty component ledger deliberately prevents +sealing. Source tests, an internal simulator build, and finding licence filenames +do not establish complete app-library notice coverage or store readiness. + +## Stable offline resource + +`assets/notices/third_party_notices.json` is a **committed platform union** of +plain `{id,title,body,androidOnly?,iosOnly?}` sections. The existing offline +Settings view imports this exact file. Its current font/icon labels remain until +actual library text is reviewed and added. Preserve all six existing sections. + +The existing `expo-asset` plugin also bundles those unmodified bytes. Expected +paths are APK `assets/third_party_notices.json`, AAB +`base/assets/third_party_notices.json`, and IPA +`Payload/Puptime.app/third_party_notices.json`. The small duplicate resource avoids +custom Hermes parsing. No tool rewrites source JSON, a generated native resource, +or a signed package. This asset/config change requires a new matching native host +and final package qualification; an older host is not equivalent. + +## Commands and failure behavior + +Run from the canonical project root, in the same production environment as its +normal bundle. `index.ts`, production Hermes transform settings and the pinned +Expo `createMetroServerAndBundleRequestAsync` implementation are reused. The graph +pass emits no JS/Hermes bundle or native compilation. Its temporary server is +closed in `finally` and on SIGINT/SIGTERM; it never creates a device. A private +cache directory plus `EXPO_NO_METRO_WORKSPACE_ROOT=1` isolates worktree resolution. + +```sh +# After normal pod install; discovery only, never a release certificate. +node scripts/capture-app-notices.mjs collect ios + +# After normal Android prebuild; resolve inputs without compiling. +# The raw Gradle input contains local cache paths: keep it private. +umask 077 +mkdir -p build/notices/android/private +(cd android && ./gradlew -I ../scripts/notices-runtime.init.gradle \ + :app:writeNoticeRuntimeInputs --no-daemon \ + -PnoticeInputsOutput="$PWD/../build/notices/android/private/runtime-inputs.json") +node scripts/capture-app-notices.mjs collect android \ + --gradle-input build/notices/android/private/runtime-inputs.json + +# Candidate gate, after reviewing/committing the union and ledger, before compile. +node scripts/capture-app-notices.mjs seal android \ + --gradle-input build/notices/android/private/runtime-inputs.json + +# After the ordinary single Gradle build; repeat verify for the AAB. +node scripts/capture-app-notices.mjs verify android \ + --gradle-input build/notices/android/private/runtime-inputs.json \ + --archive android/app/build/outputs/apk/release/app-release.apk \ + --bundle android/app/build/generated/assets/react/release/index.android.bundle \ + --source-map android/app/build/generated/sourcemaps/react/release/index.android.bundle.map +``` + +`collect` writes `build/notices//discovery.json`, always +`status: INCOMPLETE`, `complete: false`, `discoveryOnly: true`. Even fully mapped +discovery inputs are not certified. It produces neither a payload nor a seal. +Unexpected graph/source/archive errors still fail the command. Reports expose +relative source names, versions, hashes and finite unresolved reasons, not cache +paths, licence source bodies, raw maps, environment values or application data. + +`seal` first retains that discovery report, then fails unless actual Metro and +native inputs are complete, all vendors/text are reviewed, font pins match, the +payload/ledger/relevant source files equal Git HEAD, and the view imports the same +resource. A passing `seal.json` records that exact source/lock/native-input graph. +Missing input metadata or a font-only placeholder cannot pass. A failed seal +removes previous seal/final receipts. There is no generation or fallback mode. + +`verify` revalidates the same source, native inputs and committed payload, then +requires the packaged raw JSON to match byte for byte. It compares the packaged +bundle hash with the mapped build output and requires the final composed map's +source contents to equal captured source bytes. Code resolved from another +checkout, stale embedded source text, new modules and unmapped native files fail. +Only the current worktree and its explicitly identified installed `node_modules` +root are accepted, with realpaths normalized. Raw source maps stay private. + +Native stripping may change a `.so` hash. Final Android ABI/library names are +bound to the verified source AAR's actual libraries; final hashes are recorded +separately. Additional generated/NDK/Swift/framework inputs need explicit reviewed +component mappings in the ledger. Nothing is silently called app-owned. All nine +previously shipped Android LICENSE/NOTICE resources must retain their bytes. + +For an IPA, use `seal ios` after pod setup, set `SOURCEMAP_FILE` to a private +absolute path for the normal Xcode build (the installed Expo/RN script composes +Hermes maps), then `verify ios --archive --bundle +--source-map `. This helper does not build, sign, upload or submit. +Keep normal version, signing, audio, provider, device and store gates separate. + +## Reviewed ledger contract + +`scripts/app-notice-provenance.json` records exact component IDs such as +`npm:react-native@0.86.3`, `cocoapods:Expo@57.0.24`, or Maven coordinates, actual +notice source/hash sets, `reviewedVendors`, explicit `platforms`, a pinned +`upstream` reference and the combined section's `sectionSHA256`. Deterministic +section IDs are `library-` plus the first 24 SHA-256 hex characters of the exact +component ID. Body text is the actual notice files, sorted by logical source and +joined with two newlines. Keep separate NOTICE/multiple-vendor texts. + +When an AAR/JAR omits full text, an explicitly reviewed `additionalNoticeFiles` +entry may point at an actual pinned upstream copy under `assets/notices/sources/` +with `file`, `sha256`, `upstream`. It supplements discovered notices; it cannot +replace a missing NOTICE with a package label or an assumed parent licence. +Generated Metro sources require an explicit `generatedSources` provider/hash +mapping. Final native files not identified from resolved AARs require an exact +platform/path `packagedNative` mapping with covered `components` and a pinned +`review` reference. These mappings are intentionally empty until discovery. + +Metro inputs are selected files, not every declared or installed package. Nested +packages have their own licence boundary. CocoaPods inputs include resolved pod +versions, generated acknowledgements, actual source notice files and vendored +framework/library names. Gradle inputs are the actual release runtime resolution +and artifact hashes, including autolinked native projects. Missing source/text, +unreviewed vendor boundaries and changed digests stay unresolved. A POM/SPDX label +alone is insufficient. This is scoped notice evidence, not a complete transitive +SBOM or an independent legal certification. + +## Proposed workflow wiring — not active in this preparation + +No build workflow is changed here. Review this exact behavior before wiring: + +1. Normal simulator CI: after `pod install`, run **collect only**, with an + explicit INCOMPLETE/non-release-qualified step name and job-summary notice. + Upload only sanitized `discovery.json`/finite failure metadata. Do not upload + `private/`, raw Gradle inputs, source maps or raw collector logs. A successful + internal test host does not certify rights coverage. +2. Android candidate producer: after prebuild resolve the Gradle input, then + **seal before** the existing `assembleRelease bundleRelease`. Give seal and + build identical public build environment/entry/transform settings. Missing + metadata stops before compile, preserving sanitized discovery for review. + Commit the reviewed union/ledger first; do not plan a throwaway native build + or overwrite assets during the build. +3. After the ordinary build, run strict **verify for both APK and AAB before** + artifact uploads or the existing publisher. Only exact READY receipts and + package hashes qualify; no `continue-on-error` or placeholder success. +4. A device/iOS release producer must similarly seal before compilation and + verify its actual IPA before sealing/submission. There is no current automated + device-build workflow to amend; wiring must name its actual producer rather + than imply a simulator receipt qualifies an IPA. +5. Keep one affected offline-view UI proof plus final resource byte proof. Actual + new host/resource rendering, complete native input capture and final-artifact + execution are **pending**, not supplied by these source tests. + +Current npm/lock/font inputs and prior packaged Android notices are available. +Current generated CocoaPods acknowledgements, complete resolved Gradle artifacts, +final candidate maps and native-runtime metadata were not preserved in the prior +QA packet. Obtain them in the otherwise-required native setup. If incomplete, +stop before candidate compilation, retain discovery, review/commit real text and +resume the normal build; do not manufacture complete payloads now. diff --git a/scripts/app-notice-provenance.json b/scripts/app-notice-provenance.json new file mode 100644 index 00000000..f8ea7f4a --- /dev/null +++ b/scripts/app-notice-provenance.json @@ -0,0 +1,24 @@ +{ + "schema": 1, + "scope": "INCOMPLETE preparation only. Collect actual Metro/native inputs and commit reviewed platform-union text and mappings before sealing. Empty mappings deliberately fail strict verification; discovery never writes a release payload.", + "components": {}, + "generatedSources": {}, + "packagedNative": {"ios": {}, "android": {}}, + "knownLicenseSources": [ + {"component":"react-native@0.86.3","source":"node_modules/react-native/LICENSE","sha256":"da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93","upstream":"https://github.com/react/react-native/blob/v0.86.3/LICENSE"}, + {"component":"expo@57.0.24","source":"node_modules/expo/LICENSE","sha256":"fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4","upstream":"https://github.com/expo/expo/blob/sdk-57/LICENSE"}, + {"component":"posthog-react-native@4.84.1","source":"node_modules/posthog-react-native/LICENSE","sha256":"d3fb92ac01f0bef4078f89aac9f20f76da3383ad45becf6204abdf5495f43f8f","note":"Actual LICENSE includes Apache/Mixpanel text despite MIT package metadata; preserve actual files and review vendored sources."}, + {"component":"better-auth@1.7.12","source":"node_modules/better-auth/LICENSE.md","sha256":"7a07f7d9085cb3157e4377d944988c9712933141b0f7201955ceccfac92abde4"} + ], + "retainedAndroidResources": { + "META-INF/androidx/annotation/annotation/LICENSE.txt":"809fa1ed21450f59827d1e9aec720bbc4b687434fa22283c6cb5dd82a47ab9c0", + "META-INF/androidx/collection/collection-ktx/LICENSE.txt":"809fa1ed21450f59827d1e9aec720bbc4b687434fa22283c6cb5dd82a47ab9c0", + "META-INF/androidx/collection/collection/LICENSE.txt":"809fa1ed21450f59827d1e9aec720bbc4b687434fa22283c6cb5dd82a47ab9c0", + "META-INF/androidx/glance/glance-appwidget-external-protobuf/LICENSE.txt":"a01b712fbb30f80b86851468b6fa58e80eab68f69f325e88bbc7ddf47cf1b063", + "META-INF/androidx/glance/glance-appwidget-proto/LICENSE.txt":"809fa1ed21450f59827d1e9aec720bbc4b687434fa22283c6cb5dd82a47ab9c0", + "META-INF/androidx/lifecycle/lifecycle-common-java8/LICENSE.txt":"809fa1ed21450f59827d1e9aec720bbc4b687434fa22283c6cb5dd82a47ab9c0", + "META-INF/androidx/lifecycle/lifecycle-common/LICENSE.txt":"809fa1ed21450f59827d1e9aec720bbc4b687434fa22283c6cb5dd82a47ab9c0", + "META-INF/androidx/room/room-common/LICENSE.txt":"809fa1ed21450f59827d1e9aec720bbc4b687434fa22283c6cb5dd82a47ab9c0", + "okhttp3/internal/publicsuffix/NOTICE":"8a9c58fbded5ea3474315e4a9824ca8b1486098a1a03e897e0b19419d5e1876a" + } +} diff --git a/scripts/app-notices.mjs b/scripts/app-notices.mjs new file mode 100644 index 00000000..43e96195 --- /dev/null +++ b/scripts/app-notices.mjs @@ -0,0 +1,189 @@ +// Build-time notice evidence. Importing never starts Metro, a device or a build. +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {existsSync,readFileSync,readdirSync,realpathSync,statSync} from 'node:fs'; +import {dirname,isAbsolute,join,relative,resolve,sep} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +export const digest=bytes=>createHash('sha256').update(bytes).digest('hex'); +const inside=(root,path)=>path===root||path.startsWith(root+sep); +const noticeName=/^(licen[cs]es?|notices?|copying|copyright|third[._-]?party[._-]?(?:licen[cs]es?|notices?))([._-].*)?$/i; +const vendorName=/^(vendor|vendored|third[-_]?party)$/i; +const maximumTextBytes=1024*1024; +export function safeRelative(value){ + assert.equal(typeof value,'string');assert.ok(value&&!isAbsolute(value)&&!/^\w:[\\/]/.test(value)&&!value.split(/[\\/]/).includes('..')); + return value.replaceAll('\\','/'); +} +export function noticeText(bytes,maximum=maximumTextBytes){ + assert.ok(bytes.length>0&&bytes.length<=maximum,'Empty or oversized notice.'); + const text=bytes.toString('utf8');assert.ok(text.trim()&&!text.includes('\0')&&Buffer.from(text).equals(bytes),'Notice must be readable UTF-8.');return text; +} +function candidate(root,path,label){ + const bytes=readFileSync(path);noticeText(bytes);return {source:label??safeRelative(relative(root,path)),sha256:digest(bytes),body:bytes.toString('utf8')}; +} +function nearbyNotices(boundary,start,prefix){ + const result=new Map(); + for(let folder=start;inside(boundary,folder);folder=dirname(folder)){ + for(const name of readdirSync(folder).filter(name=>noticeName.test(name))){ + const path=join(folder,name);assert.ok(inside(boundary,realpathSync(path)),'Notice link escapes its component.'); + const files=statSync(path).isFile()?[path]:readdirSync(path,{recursive:true,withFileTypes:true}).filter(value=>value.isFile()).map(value=>join(value.parentPath??value.path,value.name));assert.ok(files.length<=10000); + for(const file of files){assert.ok(inside(boundary,realpathSync(file)));result.set(file,candidate(boundary,file,prefix+'/'+safeRelative(relative(boundary,file))));} + } + if(folder===boundary)break; + } + return [...result.values()]; +} + +// Use the actual Metro-selected files, not all installed/declared dependencies. +// Nested packages never inherit another package's license or review decision. +export function npmNoticeInputs(root,modulePaths){ + const modules=realpathSync(join(root,'node_modules')),project=realpathSync(root),lock=JSON.parse(readFileSync(join(root,'package-lock.json'))),components=new Map(); + for(const supplied of modulePaths){ + const path=realpathSync(supplied); + if(inside(project,path)&&!inside(modules,path))continue; + assert.ok(inside(modules,path),'Metro source outside the project and installed modules.'); + let folder=dirname(path),pkg; + while(inside(modules,folder)){ + if(existsSync(join(folder,'package.json'))){const value=JSON.parse(readFileSync(join(folder,'package.json')));if(value.name&&value.version){pkg=value;break;}} + folder=dirname(folder); + } + assert.ok(folder!==modules&&inside(modules,folder),'Selected module has no owning package.'); + const lockPath='node_modules/'+safeRelative(relative(modules,folder)); + assert.ok(pkg.name&&pkg.version&&lock.packages[lockPath]?.version===pkg.version,'Selected package differs from package-lock.'); + const id=`npm:${pkg.name}@${pkg.version}`,item=components.get(id)??{id,name:pkg.name,version:pkg.version,ecosystem:'npm',notices:[],vendors:[]}; + const moduleRelative=safeRelative(relative(folder,path)),parts=moduleRelative.split('/'); + for(let i=0;i({...item,vendors:[...new Set(item.vendors)].sort(),notices:[...new Map(item.notices.map(value=>[value.source,value])).values()].sort((a,b)=>a.source.localeCompare(b.source))})).sort((a,b)=>a.id.localeCompare(b.id)); +} + +// Native adapters must supply actual resolved coordinates and actual text, +// never a POM SPDX label or an assumed parent package's permission notice. +export function nativeNoticeInput({id,name,version,ecosystem,notices,vendors=[]}){ + assert.ok(['cocoapods','maven','native'].includes(ecosystem)&&id&&name&&version); + assert.ok(Array.isArray(notices)&&Array.isArray(vendors)); + return {id,name,version,ecosystem,vendors,notices:notices.map(value=>{ + assert.ok(value.source&&!isAbsolute(value.source)&&!value.source.includes('..')); + const body=noticeText(Buffer.from(value.body));assert.equal(digest(body),value.sha256);return {...value,body}; + })}; +} + +export function resolveNoticeCoverage(components,ledger,platform){ + assert.equal(ledger.schema,1);const unresolved=[],sections=[],seen=new Set(),records=[]; + for(const item of components){ + assert.ok(!seen.has(item.id),'Duplicate resolved component.');seen.add(item.id); + const approved=ledger.components[item.id]; + const record={component:item.id,name:item.name,version:item.version,ecosystem:item.ecosystem,vendors:item.vendors,notices:item.notices.map(({source,sha256})=>({source,sha256}))};records.push(record); + const reject=reason=>unresolved.push({component:item.id,reason}); + if(!item.notices.length){reject('missing-notice-text');continue;} + if(!approved){reject('unreviewed-component');continue;} + if(!Array.isArray(approved.reviewedVendors)||item.vendors.some(value=>!approved.reviewedVendors.includes(value))){reject('unreviewed-vendored-source');continue;} + const actual=item.notices.map(({source,sha256})=>({source,sha256})).sort((a,b)=>a.source.localeCompare(b.source)); + const expected=[...(approved.notices??[])].sort((a,b)=>a.source.localeCompare(b.source)); + if(JSON.stringify(actual)!==JSON.stringify(expected)){reject('notice-set-or-digest-mismatch');continue;} + assert.ok(typeof approved.upstream==='string'&&approved.upstream.startsWith('https://'),'Review needs a pinned upstream reference.'); + assert.ok(Array.isArray(approved.platforms)&&approved.platforms.length&&new Set(approved.platforms).size===approved.platforms.length&&approved.platforms.every(value=>['ios','android'].includes(value)),'Review needs explicit platform scope.'); + if(platform&&!approved.platforms.includes(platform)){reject('component-not-reviewed-for-platform');continue;} + const sectionId='library-'+digest(item.id).slice(0,24); + record.sectionId=sectionId; + sections.push({id:sectionId,title:`${item.name} ${item.version}`,body:[...item.notices].sort((a,b)=>a.source.localeCompare(b.source)).map(value=>value.body).join('\n\n'),...(approved.platforms.length===1?{[approved.platforms[0]==='android'?'androidOnly':'iosOnly']:true}:{})}); + } + return {status:unresolved.length?'INCOMPLETE':'READY',components:records,unresolved,sections}; +} +// Strict sealing verifies a committed platform union; it never rewrites it. +export function assertSourceNoticePayload(payload,coverage,ledger,fontProvenance){ + assert.equal(coverage.status,'READY');assert.equal(coverage.unresolved.length,0);assert.ok(coverage.components.length); + const sections=JSON.parse(noticeText(payload,16*maximumTextBytes));assert.ok(Array.isArray(sections));assert.equal(new Set(sections.map(value=>value.id)).size,sections.length); + const fonts=fontProvenance.notices; + for(const [id,expected] of Object.entries(fonts)){const value=sections.find(value=>value.id===id);assert.ok(value);assert.equal(digest(value.body),expected);} + for(const value of coverage.sections)assert.deepEqual(sections.find(section=>section.id===value.id),value,'Actual included library differs from the committed union.'); + const known=new Map(Object.entries(ledger.components).map(([id,value])=>['library-'+digest(id).slice(0,24),value])); + for(const section of sections){ + if(Object.hasOwn(fonts,section.id))continue; + const approved=known.get(section.id);assert.ok(approved,'Unreviewed source notice section.');assert.equal(digest(section.body),approved.sectionSHA256,'Union text must match its reviewed provenance.'); + assert.ok(!(section.androidOnly&§ion.iosOnly)); + if(approved.platforms.length===1)assert.equal(section[approved.platforms[0]==='android'?'androidOnly':'iosOnly'],true);else assert.ok(!section.androidOnly&&!section.iosOnly); + } + return sections; +} + +// Explicit reviewed upstream copies can supply text absent from a published +// AAR/JAR. They supplement, never replace, the artifact's discovered NOTICEs. +export function addReviewedNoticeFiles(root,components,ledger){ + return components.map(item=>({...item,notices:[...item.notices,...(ledger.components[item.id]?.additionalNoticeFiles??[]).map(value=>{ + safeRelative(value.file);assert.ok(value.file.startsWith('assets/notices/sources/')&&value.upstream?.startsWith('https://')); + const source=candidate(root,join(root,value.file));assert.equal(source.sha256,value.sha256);return source; + })].sort((a,b)=>a.source.localeCompare(b.source))})); +} + +const codeSource=path=>/\.(?:[cm]?js|jsx|tsx?|json)$/.test(path); +export function capturedSource(root,supplied,ledger={generatedSources:{}}){ + root=realpathSync(root);const modules=realpathSync(join(root,'node_modules')); + if(Object.hasOwn(ledger.generatedSources??{},supplied)){ + const value=ledger.generatedSources[supplied];safeRelative(value.provider);assert.ok(value.provider.startsWith('node_modules/')); + const provider=realpathSync(join(root,value.provider));assert.ok(inside(modules,provider));assert.equal(digest(readFileSync(provider)),value.sha256); + return {path:supplied,generated:true,provider:value.provider,sha256:value.sha256,component:npmNoticeInputs(root,[provider])[0].id}; + } + assert.ok(!/^[a-z]+:\/\//i.test(supplied)||supplied.startsWith('file://'),'Remote source-map paths are unsupported.'); + const path=realpathSync(supplied.startsWith('file://')?fileURLToPath(supplied):resolve(root,supplied)); + const installed=inside(modules,path);assert.ok(installed||inside(root,path),'App source resolved from another checkout.'); + const name=installed?'node_modules/'+safeRelative(relative(modules,path)):safeRelative(relative(root,path)); + return {path:name,sha256:digest(readFileSync(path)),code:codeSource(name),...(installed?{component:npmNoticeInputs(root,[path])[0].id}:{app:true})}; +} +export function assertCapturedSources(root,sources,readCommitted){ + assert.ok(sources.length);assert.equal(new Set(sources.map(value=>value.path)).size,sources.length); + for(const value of sources){ + if(value.generated)continue; + const bytes=readFileSync(join(root,value.path));assert.equal(digest(bytes),value.sha256,'Captured input changed.'); + if(value.app)assert.ok(bytes.equals(readCommitted(value.path)),'App graph does not equal its committed source.'); + } +} +export function assertFinalSourceMap(root,map,sources,ledger){ + const expected=new Map(sources.map(value=>[value.path,value])),observed=new Set(); + function check(value){ + assert.equal(value.version,3);assert.ok(!value.sourceRoot,'Use a map rooted at the canonical project.'); + if(value.sections){for(const section of value.sections){assert.ok(section.map&&!section.url);check(section.map);}return;} + assert.ok(Array.isArray(value.sources)&&Array.isArray(value.sourcesContent)&&value.sources.length===value.sourcesContent.length); + for(let i=0;ivalue.app&&observed.has(value.path)).length}; +} + +export function assertPackagedNativeCoverage(platform,files,resolvedNative,coverage,ledger){ + const covered=new Set(coverage.components.map(value=>value.component)); + return files.map(file=>{ + const path=safeRelative(file.path),matches=resolvedNative.filter(input=>{ + // APK/AAB stripping can alter bytes. Bind exact ABI/library names to the + // verified source artifact; record final bytes separately, never equate them. + const source=input.path.match(/(?:^|!)jni\/(.+\.so)$/)?.[1]; + return platform==='android'&&source&&(path==='lib/'+source||path==='base/lib/'+source); + }); + const approved=ledger.packagedNative?.[platform]?.[path]; + const components=[...new Set(matches.map(value=>value.component))]; + if(approved){assert.ok(approved.review?.startsWith('https://'));components.push(...approved.components);} + assert.ok(components.length&&components.every(id=>covered.has(id)),'Unmapped packaged native input.'); + return {path,sha256:file.sha256,components:[...new Set(components)].sort()}; + }); +} +export function publicNoticeReport({platform,sourceCommit,entry,lockSHA256,coverage,payload}){ + assert.ok(['ios','android'].includes(platform));assert.match(sourceCommit,/^[a-f0-9]{40}$/);safeRelative(entry); + if(payload)assert.equal(coverage.status,'READY'); + return {schema:1,platform,sourceCommit,entry,lockSHA256,scope:payload?'precompile-input-verification':'resolved-input-discovery',status:payload?'READY':'INCOMPLETE',complete:Boolean(payload),components:coverage.components,unresolved:coverage.unresolved,...(payload?{payloadSHA256:digest(payload)}:{discoveryOnly:true})}; +} +export function assertFinalNoticeEvidence({manifest,payload,finalComponents,resources,retainedResources={}}){ + assert.equal(manifest.status,'READY');assert.equal(manifest.complete,true);assert.deepEqual(manifest.unresolved,[]); + assert.equal(digest(payload),manifest.payloadSHA256,'Final packaged payload differs from captured source.'); + const data=JSON.parse(noticeText(payload,16*maximumTextBytes));assert.ok(Array.isArray(data)); + for(const component of manifest.components){const section=data.find(value=>value.id===component.sectionId);assert.ok(section?.body,'A resolved library has no readable notice section.');} + assert.deepEqual([...new Set(finalComponents)].sort(),manifest.components.map(value=>value.component).sort(),'Final runtime inventory differs from captured coverage.'); + for(const [name,expected] of Object.entries(retainedResources))assert.equal(resources[name],expected,'An existing shipped native notice was removed or changed.'); +} diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs new file mode 100644 index 00000000..11b7f4be --- /dev/null +++ b/scripts/app-notices.test.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import test from 'node:test'; +import {npmNoticeInputs,nativeNoticeInput,resolveNoticeCoverage,assertSourceNoticePayload,publicNoticeReport,assertFinalNoticeEvidence,capturedSource,assertCapturedSources,assertFinalSourceMap,assertPackagedNativeCoverage,digest} from './app-notices.mjs'; +import {captureResolvedInputs} from './capture-app-notices.mjs'; + +const mit=readFileSync(new URL('../node_modules/react-native/LICENSE',import.meta.url),'utf8'); +function fixture(run){ + const root=mkdtempSync(join(tmpdir(),'puptime-notice-check-')); + const file=(path,body)=>{mkdirSync(join(root,path,'..'),{recursive:true});writeFileSync(join(root,path),typeof body==='string'?body:JSON.stringify(body));return join(root,path);}; + try{return run({root,file});}finally{rmSync(root,{recursive:true,force:true});} +} +const review=item=>({reviewedVendors:item.vendors,platforms:['ios','android'],sectionSHA256:digest([...item.notices].sort((a,b)=>a.source.localeCompare(b.source)).map(value=>value.body).join('\n\n')),upstream:'https://example.invalid/pinned/source',notices:item.notices.map(({source,sha256})=>({source,sha256}))}); +const ledger=(...items)=>({schema:1,components:Object.fromEntries(items.map(item=>[item.id,review(item)]))}); +const font={id:'fixture-font',title:'Fixture font',body:mit},fonts={notices:{'fixture-font':digest(mit)}}; +const sourcePayload=coverage=>Buffer.from(JSON.stringify([font,...coverage.sections],null,2)+'\n'); + +test('initial upstream inventory hashes the actual installed licence texts without certifying component coverage',()=>{ + const preparation=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))); + for(const value of preparation.knownLicenseSources)assert.equal(digest(readFileSync(new URL('../'+value.source,import.meta.url))),value.sha256); +}); + +test('actual selected packages keep nested and vendored rights separate, without parent-license inheritance',()=>fixture(({root,file})=>{ + file('package-lock.json',{packages:{'node_modules/parent':{version:'1.0.0'},'node_modules/parent/node_modules/child':{version:'2.0.0'}}}); + file('node_modules/parent/package.json',{name:'parent',version:'1.0.0'});file('node_modules/parent/LICENSE',mit); + file('node_modules/parent/lib/package.json',{type:'commonjs'}); + const selected=file('node_modules/parent/lib/main.js','export const value=1;'); + file('node_modules/parent/node_modules/child/package.json',{name:'child',version:'2.0.0'}); + const child=file('node_modules/parent/node_modules/child/main.js','export const value=2;'); + const inputs=npmNoticeInputs(root,[selected,child]);assert.equal(inputs.length,2); + const parent=inputs.find(value=>value.name==='parent'),nested=inputs.find(value=>value.name==='child'); + assert.equal(parent.notices[0].body,mit);assert.deepEqual(nested.notices,[]); + assert.deepEqual(resolveNoticeCoverage(inputs,ledger(parent)).unresolved,[{component:nested.id,reason:'missing-notice-text'}]); + const vendor=file('node_modules/parent/vendor/separate/main.js','module.exports=1');file('node_modules/parent/vendor/separate/NOTICE','Separate upstream notice\n'); + const vendored=npmNoticeInputs(root,[vendor])[0];assert.deepEqual(vendored.vendors,['vendor/separate']);assert.equal(vendored.notices.length,2); + const stale=ledger(vendored);stale.components[vendored.id].reviewedVendors=[]; + assert.equal(resolveNoticeCoverage([vendored],stale).status,'INCOMPLETE'); + assert.equal(resolveNoticeCoverage([vendored],ledger(vendored)).status,'READY'); +})); + +test('a complete package label does not excuse missing or modified actual notice/NOTICE files',()=>fixture(({root,file})=>{ + file('package-lock.json',{packages:{'node_modules/qa':{version:'1.0.0'}}});file('node_modules/qa/package.json',{name:'qa',version:'1.0.0',license:'MIT'}); + const source=file('node_modules/qa/main.js',''); + const missing=npmNoticeInputs(root,[source]);assert.equal(resolveNoticeCoverage(missing,{schema:1,components:{}}).unresolved[0].reason,'missing-notice-text'); + file('node_modules/qa/LICENSE',mit);file('node_modules/qa/NOTICE','Preserve the vendored attribution\n'); + file('node_modules/qa/LICENSE-MIT',mit);file('node_modules/qa/LICENSES/Vendor-A.txt','Distinct actual vendor licence\n');file('node_modules/qa/THIRD_PARTY_NOTICES.md','More attribution\n'); + const input=npmNoticeInputs(root,[source])[0],approved=ledger(input);assert.equal(input.notices.length,5); + approved.components[input.id].notices.pop();assert.equal(resolveNoticeCoverage([input],approved).status,'INCOMPLETE'); + const before=ledger(input);file('node_modules/qa/LICENSE',mit+'modified'); + assert.equal(resolveNoticeCoverage(npmNoticeInputs(root,[source]),before).status,'INCOMPLETE'); + file('node_modules/qa/package.json',{name:'qa',version:'2.0.0'});assert.throws(()=>npmNoticeInputs(root,[source])); +})); + +test('collection remains explicitly incomplete and cannot create a release payload or disclose filesystem paths',()=>{ + const item=nativeNoticeInput({id:'maven:qa:library:1',name:'qa:library',version:'1',ecosystem:'maven',notices:[{source:'aar:qa-library.aar!META-INF/LICENSE',body:mit,sha256:digest(mit)}]}); + const missing=resolveNoticeCoverage([item],{schema:1,components:{}});assert.equal(missing.status,'INCOMPLETE');assert.throws(()=>assertSourceNoticePayload(sourcePayload(missing),missing,{schema:1,components:{}},fonts)); + const ready=resolveNoticeCoverage([item],ledger(item)); + const report=publicNoticeReport({platform:'android',sourceCommit:'a'.repeat(40),entry:'index.ts',lockSHA256:'b'.repeat(64),coverage:ready}); + assert.equal(report.status,'INCOMPLETE');assert.equal(report.complete,false);assert.equal(report.payloadSHA256,undefined);assert.equal(report.discoveryOnly,true); + assert.ok(!JSON.stringify(report).includes(tmpdir())); + assert.throws(()=>nativeNoticeInput({...item,notices:[{...item.notices[0],source:'/private/owner/credential'}]})); + assert.throws(()=>assertSourceNoticePayload(Buffer.from(JSON.stringify([font])),{status:'READY',components:[],unresolved:[],sections:[]},{schema:1,components:{}},fonts)); +}); + +test('final evidence requires the exact readable payload, runtime coverage and every retained native notice',()=>{ + const item=nativeNoticeInput({id:'cocoapods:QA@1',name:'QA',version:'1',ecosystem:'cocoapods',notices:[{source:'pod:QA/LICENSE',body:mit,sha256:digest(mit)}]}); + const coverage=resolveNoticeCoverage([item],ledger(item)),payload=sourcePayload(coverage); + const manifest=publicNoticeReport({platform:'ios',sourceCommit:'a'.repeat(40),entry:'index.ts',lockSHA256:'b'.repeat(64),coverage,payload}); + assertSourceNoticePayload(payload,coverage,ledger(item),fonts); + const input={manifest,payload,finalComponents:[item.id],resources:{'META-INF/existing/NOTICE':'c'.repeat(64)},retainedResources:{'META-INF/existing/NOTICE':'c'.repeat(64)}}; + assertFinalNoticeEvidence(input); + assert.throws(()=>assertFinalNoticeEvidence({...input,payload:Buffer.from('[]')})); + assert.throws(()=>assertFinalNoticeEvidence({...input,finalComponents:[item.id,'native:unmapped@1']})); + assert.throws(()=>assertFinalNoticeEvidence({...input,resources:{}})); + assert.throws(()=>assertFinalNoticeEvidence({...input,manifest:{...manifest,complete:false}})); + const changed=Buffer.from(payload.toString().replace('library-','missing-')); + assert.throws(()=>assertFinalNoticeEvidence({...input,payload:changed,manifest:{...manifest,payloadSHA256:digest(changed)}})); + assert.throws(()=>assertSourceNoticePayload(changed,coverage,ledger(item),fonts)); + const drift=ledger(item);drift.components[item.id].sectionSHA256='d'.repeat(64);assert.throws(()=>assertSourceNoticePayload(payload,coverage,drift,fonts)); + const wrongPlatform=ledger(item);wrongPlatform.components[item.id].platforms=['android']; + assert.equal(resolveNoticeCoverage([item],wrongPlatform,'ios').unresolved[0].reason,'component-not-reviewed-for-platform'); +}); + +test('worktree source proof rejects a foreign checkout and stale final sourcesContent even with identical package coverage',()=>fixture(({root,file})=>{ + file('node_modules/.placeholder','');file('package-lock.json',{packages:{}}); + const content='export const guide = "current";\n',path=file('src/LearnScreen.tsx',content),source=capturedSource(root,path); + assertCapturedSources(root,[source],()=>Buffer.from(content)); + const map={version:3,sources:['src/LearnScreen.tsx'],sourcesContent:[content]}; + assertFinalSourceMap(root,map,[source],{}); + assert.throws(()=>assertFinalSourceMap(root,{...map,sourcesContent:['export const guide = "stale";\n']},[source],{})); + assert.throws(()=>assertCapturedSources(root,[source],()=>Buffer.from('another frozen source'))); + fixture(other=>{const foreign=other.file('src/LearnScreen.tsx',content);assert.throws(()=>capturedSource(root,foreign));assert.throws(()=>assertFinalSourceMap(root,{...map,sources:[foreign]},[source],{}));}); + file('src/LearnScreen.tsx',content+'// changed\n');assert.throws(()=>assertFinalSourceMap(root,map,[source],{})); +})); + +test('unknown packaged native libraries cannot be certified by a complete JS inventory',()=>{ + const component='maven:qa:native@1',coverage={components:[{component}]}; + const file={path:'lib/arm64-v8a/libqa.so',sha256:'a'.repeat(64)},input={path:'jni/arm64-v8a/libqa.so',component,sha256:'b'.repeat(64)}; + assert.equal(assertPackagedNativeCoverage('android',[file],[input],coverage,{})[0].components[0],component); + assert.throws(()=>assertPackagedNativeCoverage('android',[{...file,path:'lib/arm64-v8a/libunknown.so'}],[input],coverage,{})); + const framework={path:'Payload/Puptime.app/Frameworks/QA.framework/QA',sha256:'c'.repeat(64)}; + assert.throws(()=>assertPackagedNativeCoverage('ios',[framework],[],coverage,{})); + const reviewed={packagedNative:{ios:{[framework.path]:{components:[component],review:'https://example.invalid/pinned/native-source'}}}}; + assert.equal(assertPackagedNativeCoverage('ios',[framework],[],coverage,reviewed).length,1); + assert.throws(()=>assertPackagedNativeCoverage('ios',[framework],[],{components:[]},reviewed)); +}); + +test('real resolved-input collection leaves missing native/generated review incomplete without changing the tracked payload',()=>fixture(({root,file})=>{ + file('package-lock.json',{packages:{'node_modules/qa':{version:'1'}}});file('node_modules/qa/package.json',{name:'qa',version:'1'});file('node_modules/qa/LICENSE',mit); + const path=file('node_modules/qa/index.js','module.exports=1;'); + file('scripts/app-notice-provenance.json',{schema:1,components:{},generatedSources:{}});file('scripts/font-icon-provenance.json',fonts); + const payload=file('assets/notices/third_party_notices.json',JSON.stringify([font])),before=readFileSync(payload); + const result=captureResolvedInputs(root,'ios',[path,'__prelude__']); + assert.equal(result.coverage.status,'INCOMPLETE');assert.ok(result.coverage.unresolved.some(value=>value.reason==='missing-resolved-pod-inputs')); + assert.ok(result.coverage.unresolved.some(value=>value.reason==='unreviewed-generated-source')); + assert.ok(readFileSync(payload).equals(before));assert.ok(!JSON.stringify(result).includes(root)); +})); diff --git a/scripts/capture-app-notices.mjs b/scripts/capture-app-notices.mjs new file mode 100644 index 00000000..6138a934 --- /dev/null +++ b/scripts/capture-app-notices.mjs @@ -0,0 +1,119 @@ +// No source generation. Discovery is incomplete; seal/verify fail closed. +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {chmodSync,existsSync,lstatSync,mkdirSync,readFileSync,realpathSync,rmSync,statSync,writeFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; +import {dirname,join,relative,resolve,sep} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {addReviewedNoticeFiles,assertCapturedSources,assertFinalNoticeEvidence,assertFinalSourceMap,assertPackagedNativeCoverage,assertSourceNoticePayload,capturedSource,digest,npmNoticeInputs,publicNoticeReport,resolveNoticeCoverage} from './app-notices.mjs'; +import {gradleNoticeInputs,inspectNoticeArchive,podNoticeInputs,unionNoticeInputs} from './native-notice-inputs.mjs'; +import {installTaskCleanup} from './native-task-cleanup.mjs'; + +export const payloadPath='assets/notices/third_party_notices.json'; +const ledgerPath='scripts/app-notice-provenance.json',fontPath='scripts/font-icon-provenance.json',entry='index.ts'; +const readJSON=path=>{assert.ok(statSync(path).size<=128*1024*1024);return JSON.parse(readFileSync(path,'utf8'));}; +const git=(root,...args)=>execFileSync('git',args,{cwd:root,maxBuffer:32*1024*1024,stdio:['ignore','pipe','pipe']}); +const committed=(root,path)=>git(root,'show','HEAD:'+path); +function strictSource(root,path){const bytes=readFileSync(join(root,path));assert.ok(bytes.equals(committed(root,path)),'Notice source is not committed.');return bytes;} +function nativeInputs(root,platform,gradleInput){return platform==='ios'?podNoticeInputs(root):gradleNoticeInputs(root,gradleInput);} + +// Given the actual Metro-selected paths, capture component/own-source evidence. +// Injecting paths permits meaningful fixture checks without starting a bundler. +export function captureResolvedInputs(root,platform,paths,gradleInput){ + const ledger=readJSON(join(root,ledgerPath)),fonts=readJSON(join(root,fontPath)),unresolved=[],sources=[],physical=[]; + for(const path of new Set(paths)){ + if(path==='__prelude__'&&!Object.hasOwn(ledger.generatedSources??{},path)){unresolved.push({component:'metro:__prelude__',reason:'unreviewed-generated-source'});continue;} + const value=capturedSource(root,path,ledger);sources.push(value); + physical.push(join(root,value.generated?value.provider:value.path)); + } + const native=nativeInputs(root,platform,gradleInput); + const components=addReviewedNoticeFiles(root,unionNoticeInputs(npmNoticeInputs(root,physical),native.components),ledger); + const coverage=resolveNoticeCoverage(components,ledger,platform);coverage.unresolved.push(...unresolved,...native.unresolved); + if(coverage.unresolved.length)coverage.status='INCOMPLETE'; + return {ledger,fonts,coverage,sources:sources.sort((a,b)=>a.path.localeCompare(b.path)),native}; +} + +async function metroPaths(root,platform,privateRoot){ + // Same pinned export:embed loader, graph options, file-map patch and resolver + // used by native builds. No HTTP server, export, platform compile or cache wipe. + process.env.NODE_ENV='production';process.env.EXPO_NO_METRO_WORKSPACE_ROOT='1';process.env.TMPDIR=privateRoot; + const require=createRequire(import.meta.url),expoRequire=createRequire(require.resolve('expo/package.json')); + const cli=dirname(expoRequire.resolve('@expo/cli/package.json')); + const {createMetroServerAndBundleRequestAsync}=require(join(cli,'build/src/export/embed/exportEmbedAsync.js')); + let server;const cleanup=installTaskCleanup(async()=>{await server?.end();}); + // Metro diagnostics can include absolute developer paths/source excerpts. + // This standalone discovery process emits finite summaries/reports only. + const stdout=process.stdout.write,stderr=process.stderr.write;process.stdout.write=process.stderr.write=()=>true; + try{ + const result=await createMetroServerAndBundleRequestAsync(root,{entryFile:entry,platform,dev:false,minify:false,maxWorkers:2,resetCache:false});server=result.server; + return await server.getOrderedDependencyPaths(result.bundleRequest); + }finally{try{await cleanup.run();}finally{cleanup.dispose();process.stdout.write=stdout;process.stderr.write=stderr;}} +} + +function assertReviewedSource(root,capture){ + const {ledger,fonts,sources,coverage}=capture; + assertCapturedSources(root,sources,path=>committed(root,path)); + for(const path of [ledgerPath,fontPath,payloadPath])strictSource(root,path); + for(const [path,expected] of Object.entries(fonts.files))assert.equal(digest(readFileSync(join(root,path))),expected,'Installed font input drifted.'); + for(const component of Object.values(ledger.components))for(const item of component.additionalNoticeFiles??[])strictSource(root,item.file); + const payload=readFileSync(join(root,payloadPath));assertSourceNoticePayload(payload,coverage,ledger,fonts); + assert.ok(sources.some(value=>value.path===payloadPath&&value.sha256===digest(payload)),'The offline view must import the same committed resource.'); + const app=readJSON(join(root,'app.json')).expo; + assert.ok(app.plugins.some(value=>Array.isArray(value)&&value[0]==='expo-asset'&&JSON.stringify(value[1]?.assets)===JSON.stringify(['./'+payloadPath])),'Declare the exact raw notice resource through expo-asset.'); + if(existsSync(join(root,'android/app/src/main/assets/third_party_notices.json')))assert.ok(payload.equals(readFileSync(join(root,'android/app/src/main/assets/third_party_notices.json'))),'Android prebuild resource drifted.'); + return payload; +} + +function outputDirectory(root,platform){ + const path=join(root,'build/notices',platform);let current=root; + for(const part of relative(root,path).split(sep)){ + current=join(current,part);if(existsSync(current))assert.ok(!lstatSync(current).isSymbolicLink(),'Notice output must stay inside this worktree.');else mkdirSync(current,{mode:0o700}); + } + return path; +} +function write(path,value){writeFileSync(path,JSON.stringify(value,null,2)+'\n',{mode:0o600});chmodSync(path,0o600);} +function inputHashes(root,capture){return {...capture.native.inputHashes,packageLock:digest(readFileSync(join(root,'package-lock.json'))),ledger:digest(readFileSync(join(root,ledgerPath))),fontProvenance:digest(readFileSync(join(root,fontPath)))};} +const resourcePaths={ + apk:{platform:'android',payload:'assets/third_party_notices.json',bundle:'assets/index.android.bundle'}, + aab:{platform:'android',payload:'base/assets/third_party_notices.json',bundle:'base/assets/index.android.bundle'}, + ipa:{platform:'ios',payload:'Payload/Puptime.app/third_party_notices.json',bundle:'Payload/Puptime.app/main.jsbundle'}, +}; + +export async function main(args=process.argv.slice(2)){ + const [mode,platform,...tail]=args;assert.ok(['collect','seal','verify'].includes(mode)&&['ios','android'].includes(platform),'Use collect|seal|verify ios|android.'); + const options={};assert.equal(tail.length%2,0); + for(let i=0;ivalue.generated?value.path:join(root,value.path)),options['--gradle-input']); + assert.deepEqual(capture.sources,seal.sources);assert.deepEqual(inputHashes(root,capture),seal.inputHashes);assert.deepEqual(capture.coverage.components,seal.components); + const payload=assertReviewedSource(root,capture);assert.equal(digest(payload),seal.payloadSHA256); + stage='final-source';assert.ok(options['--source-map']&&options['--bundle']&&options['--archive']); + const sourceMap=readJSON(options['--source-map']),mapEvidence=assertFinalSourceMap(root,sourceMap,seal.sources,capture.ledger); + const archivePath=options['--archive'],paths=resourcePaths[kind];assert.equal(paths?.platform,platform); + stage='archive';const archive=inspectNoticeArchive(archivePath,paths.payload,paths.bundle);assert.ok(archive.payload&&archive.bundle); + assert.ok(Buffer.from(archive.payload.base64,'base64').equals(payload));assert.equal(archive.bundle.sha256,digest(readFileSync(options['--bundle'])),'Final bundle differs from the mapped build output.'); + const native=assertPackagedNativeCoverage(platform,archive.nativeFiles,capture.native.nativeFiles,capture.coverage,capture.ledger); + const resources=Object.fromEntries(archive.notices.map(value=>[kind==='aab'?value.source.replace(/^base\/root\//,''):value.source,value.sha256])); + assertFinalNoticeEvidence({manifest:seal,payload,finalComponents:capture.coverage.components.map(value=>value.component),resources,retainedResources:platform==='android'?capture.ledger.retainedAndroidResources:{}}); + write(join(out,'final-'+kind+'.json'),{schema:1,scope:'final-artifact-verification',status:'READY',sourceCommit,platform,payloadSHA256:digest(payload),archiveSHA256:digest(readFileSync(archivePath)),bundleSHA256:archive.bundle.sha256,sourceMapSHA256:digest(readFileSync(options['--source-map'])),mapEvidence,native,complete:true}); + console.log('Notices final verification: READY.');return; + } + if(mode==='seal'){rmSync(join(out,'seal.json'),{force:true});for(const kind of Object.keys(resourcePaths))rmSync(join(out,'final-'+kind+'.json'),{force:true});} + const privateRoot=join(out,'private');mkdirSync(privateRoot,{recursive:true,mode:0o700});chmodSync(privateRoot,0o700); + const paths=await metroPaths(root,platform,privateRoot);stage='native';const capture=captureResolvedInputs(root,platform,paths,options['--gradle-input']); + const report=publicNoticeReport({platform,sourceCommit,entry,lockSHA256:digest(readFileSync(join(root,'package-lock.json'))),coverage:capture.coverage}); + write(join(out,'discovery.json'),{...report,sources:capture.sources,inputHashes:inputHashes(root,capture)}); + if(mode==='collect'){console.log(`Notices discovery: INCOMPLETE (${capture.coverage.unresolved.length} unresolved inputs); no release payload generated.`);return;} + stage='seal';const payload=assertReviewedSource(root,capture); + write(join(out,'seal.json'),{...publicNoticeReport({...report,coverage:capture.coverage,payload}),sources:capture.sources,inputHashes:inputHashes(root,capture)}); + console.log('Notices precompile verification: READY.'); + }catch{ + write(join(out,'failure.json'),{schema:1,status:'INCOMPLETE',complete:false,sourceCommit,platform,stage}); + console.error(`Notices verification failed closed at ${stage}; no completed payload or release qualification.`);process.exitCode=1; + } +} +if(process.argv[1]&&realpathSync(process.argv[1])===fileURLToPath(import.meta.url))await main().catch(()=>{console.error('Invalid notice command; no release qualification.');process.exitCode=1;}); diff --git a/scripts/native-notice-inputs.mjs b/scripts/native-notice-inputs.mjs new file mode 100644 index 00000000..e548a013 --- /dev/null +++ b/scripts/native-notice-inputs.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {existsSync,readFileSync,readdirSync,realpathSync} from 'node:fs'; +import {basename,join,relative,resolve,sep} from 'node:path'; +import {createRequire} from 'node:module'; +import {digest,nativeNoticeInput,npmNoticeInputs} from './app-notices.mjs'; + +const require=createRequire(import.meta.url),archiveReader=new URL('./notice-archive.py',import.meta.url); +export function inspectNoticeArchive(path,payloadPath,bundlePath){ + return JSON.parse(execFileSync('python3',[archiveReader.pathname,path,...(payloadPath?[payloadPath]:[]),...(bundlePath?[bundlePath]:[])],{encoding:'utf8',maxBuffer:32*1024*1024,timeout:60000,stdio:['ignore','pipe','pipe']})); +} +export function podNoticeInputs(root){ + const lockPath=join(root,'ios/Podfile.lock'),support=join(root,'ios/Pods/Target Support Files'); + if(!existsSync(lockPath)||!existsSync(support))return {components:[],unresolved:[{component:'native:ios',reason:'missing-resolved-pod-inputs'}],nativeFiles:[]}; + const lock=require('yaml').parse(readFileSync(lockPath,'utf8')),plist=require('@expo/plist').default,versions=new Map(),texts=new Map(); + for(const row of lock.PODS??[]){ + const value=typeof row==='string'?row:Object.keys(row)[0],match=value.match(/^([^\s(]+) \(([^\s)]+)/);assert.ok(match,'Unsupported resolved pod entry.'); + const name=match[1].split('/')[0],version=match[2];assert.ok(!versions.has(name)||versions.get(name)===version);versions.set(name,version); + } + assert.ok(versions.size,'Missing resolved pods.'); + for(const target of readdirSync(support).sort()){ + const path=join(support,target,target+'-acknowledgements.plist');if(!existsSync(path))continue; + for(const row of plist.parse(readFileSync(path,'utf8')).PreferenceSpecifiers??[]){ + if(!versions.has(row.Title)||typeof row.FooterText!=='string')continue; + const list=texts.get(row.Title)??[],body=row.FooterText; + if(!list.some(value=>value.sha256===digest(body)))list.push({source:`cocoapods:${target}/${row.Title}`,body,sha256:digest(body)}); + texts.set(row.Title,list); + } + } + const unresolved=[],components=[]; + for(const [name,version] of versions){ + const vendors=[],notices=texts.get(name)??[],external=lock['EXTERNAL SOURCES']?.[name]?.[':path']; + const source=external?resolve(root,'ios',external):join(root,'ios/Pods',name); + if(!existsSync(source))unresolved.push({component:`cocoapods:${name}@${version}`,reason:'missing-resolved-pod-source'}); + else{ + const boundary=realpathSync(source),project=realpathSync(root),modules=existsSync(join(root,'node_modules'))?realpathSync(join(root,'node_modules')):null; + assert.ok(boundary.startsWith(project+sep)||(modules&&boundary.startsWith(modules+sep)),'Native source resolved outside this checkout and its installed modules.'); + const entries=readdirSync(boundary,{recursive:true,withFileTypes:true});assert.ok(entries.length<=100000); + for(const entry of entries){ + const path=join(entry.parentPath??entry.path,entry.name),logical=relative(boundary,path).split(sep).join('/'); + if(/\.(?:xcframework|framework|a|so)$/.test(entry.name))vendors.push(logical); + if(!entry.isFile()||!logical.split('/').some(part=>/^(licen[cs]es?|notices?|copying|copyright|third[._-]?party[._-]?(?:licen[cs]es?|notices?))([._-].*)?$/i.test(part)))continue; + assert.ok(realpathSync(path).startsWith(boundary+sep));const body=readFileSync(path,'utf8'); + notices.push({source:`cocoapods:${name}/${logical}`,body,sha256:digest(body)}); + } + } + components.push(nativeNoticeInput({id:`cocoapods:${name}@${version}`,name,version,ecosystem:'cocoapods',notices:notices.sort((a,b)=>a.source.localeCompare(b.source)),vendors:vendors.sort()})); + } + return {components,unresolved,nativeFiles:[],inputHashes:{podLock:digest(readFileSync(lockPath))}}; +} +export function gradleNoticeInputs(root,inputFile){ + if(!inputFile||!existsSync(inputFile))return {components:[],unresolved:[{component:'native:android',reason:'missing-resolved-gradle-inputs'}],nativeFiles:[]}; + const input=JSON.parse(readFileSync(inputFile));assert.equal(input.schema,1);assert.equal(input.configuration,'releaseRuntimeClasspath');assert.ok(input.components.length); + const components=[],nativeFiles=[],projectSources=[]; + for(const value of input.components){ + if(value.projectBuildFile){projectSources.push(value.projectBuildFile);continue;} + assert.ok(value.group&&value.name&&value.version&&value.artifacts.length);const notices=[],vendors=[]; + for(const artifact of value.artifacts){ + const bytes=readFileSync(artifact.file);assert.equal(digest(bytes),artifact.sha256,'Resolved artifact changed.'); + const inspected=inspectNoticeArchive(artifact.file),prefix='maven:'+value.group+':'+value.name+':'+value.version+'/'+basename(artifact.file)+'!'; + notices.push(...inspected.notices.map(item=>({...item,source:prefix+item.source}))); + nativeFiles.push(...inspected.nativeFiles.map(item=>({...item,component:`maven:${value.group}:${value.name}@${value.version}`}))); + for(const item of inspected.notices)if(/(^|\/)(vendor|third[-_]?party)(\/|$)/i.test(item.source))vendors.push(item.source); + } + components.push(nativeNoticeInput({id:`maven:${value.group}:${value.name}@${value.version}`,name:value.group+':'+value.name,version:value.version,ecosystem:'maven',notices,vendors})); + } + // Autolinked Gradle projects are actual native inputs even if their JS entry + // did not occur in Metro. Resolve their own installed package, never :app. + if(projectSources.length)components.push(...npmNoticeInputs(root,projectSources)); + return {components,nativeFiles,unresolved:[],inputHashes:{gradle:digest(readFileSync(inputFile))}}; +} +export function unionNoticeInputs(...groups){ + const merged=new Map(); + for(const item of groups.flat()){ + const previous=merged.get(item.id); + if(!previous){merged.set(item.id,item);continue;} + const notices=new Map(previous.notices.map(value=>[value.source,value])); + for(const value of item.notices){const before=notices.get(value.source);assert.ok(!before||before.sha256===value.sha256,'One source has conflicting notice bytes.');notices.set(value.source,value);} + merged.set(item.id,{...previous,vendors:[...new Set([...previous.vendors,...item.vendors])].sort(),notices:[...notices.values()].sort((a,b)=>a.source.localeCompare(b.source))}); + } + return [...merged.values()].sort((a,b)=>a.id.localeCompare(b.id)); +} diff --git a/scripts/native-notice-inputs.test.mjs b/scripts/native-notice-inputs.test.mjs new file mode 100644 index 00000000..baf84e80 --- /dev/null +++ b/scripts/native-notice-inputs.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {createRequire} from 'node:module'; +import test from 'node:test'; +import {podNoticeInputs,gradleNoticeInputs,unionNoticeInputs,inspectNoticeArchive} from './native-notice-inputs.mjs'; +import {digest,resolveNoticeCoverage} from './app-notices.mjs'; +const require=createRequire(import.meta.url),plist=require('@expo/plist'); +const mit=readFileSync(new URL('../node_modules/react-native/LICENSE',import.meta.url),'utf8'); +function fixture(run){ + const root=mkdtempSync(join(tmpdir(),'puptime-native-notice-')); + const file=(path,body)=>{mkdirSync(join(root,path,'..'),{recursive:true});writeFileSync(join(root,path),typeof body==='string'||Buffer.isBuffer(body)?body:JSON.stringify(body));return join(root,path);}; + try{return run({root,file});}finally{rmSync(root,{recursive:true,force:true});} +} +test('resolved CocoaPods subspecs and actual generated plist text determine coverage; missing native inputs stay incomplete',()=>fixture(({root,file})=>{ + assert.equal(podNoticeInputs(root).unresolved.length,1); + file('ios/Podfile.lock','PODS:\n - QA/Core (1.2.3)\n - QA/UI (1.2.3)\n - Missing (4.5.6)\n'); + file('ios/Pods/QA/NOTICE','A distinct vendored attribution\n');file('ios/Pods/QA/Dependency.xcframework/Info.plist',''); + const text=plist.default.build; + file('ios/Pods/Target Support Files/Pods-Puptime/Pods-Puptime-acknowledgements.plist',text({PreferenceSpecifiers:[{Title:'Acknowledgements',FooterText:'Not a library'},{Title:'QA',FooterText:mit}]})); + const input=podNoticeInputs(root);assert.equal(input.components.length,2); + assert.ok(input.components.find(value=>value.name==='QA').notices.some(value=>value.body===mit)); + assert.ok(input.components.find(value=>value.name==='QA').notices.some(value=>value.body==='A distinct vendored attribution\n')); + assert.deepEqual(input.components.find(value=>value.name==='QA').vendors,['Dependency.xcframework']); + assert.equal(input.components.find(value=>value.name==='Missing').notices.length,0); + assert.equal(resolveNoticeCoverage(input.components,{schema:1,components:{}}).status,'INCOMPLETE'); +})); + +test('actual AAR and nested JAR LICENSE plus NOTICE text are retained, with artifact digest and native-library provenance',()=>fixture(({root,file})=>{ + const aar=join(root,'fixture.aar'); + execFileSync('python3',['-c',`import io,zipfile,sys\nb=io.BytesIO()\nwith zipfile.ZipFile(b,'w') as z:z.writestr('META-INF/NOTICE','Nested attribution\\n')\nwith zipfile.ZipFile(sys.argv[1],'w') as z:\n z.writestr('META-INF/LICENSE',sys.argv[2]);z.writestr('classes.jar',b.getvalue());z.writestr('jni/arm64-v8a/libqa.so',b'\\x7fELFfixture')`,aar,mit]); + const input={schema:1,configuration:'releaseRuntimeClasspath',components:[{group:'qa',name:'native',version:'1.2.3',artifacts:[{file:aar,sha256:digest(readFileSync(aar))}]}]}; + const path=file('private-gradle-input.json',input),read=gradleNoticeInputs(root,path); + assert.equal(read.components.length,1);assert.equal(read.components[0].notices.length,2);assert.equal(read.nativeFiles.length,1); + assert.ok(read.components[0].notices.some(value=>value.body===mit));assert.ok(read.components[0].notices.some(value=>value.body==='Nested attribution\n')); + assert.ok(!JSON.stringify(read).includes(root),'Public notice inputs must omit private artifact paths.'); + input.components[0].artifacts[0].sha256='0'.repeat(64);file('private-gradle-input.json',input);assert.throws(()=>gradleNoticeInputs(root,path)); +})); + +test('archive reader rejects duplicate/traversal input, and reads the exact final JSON resource without extraction',()=>fixture(({root})=>{ + const path=join(root,'fixture.apk'),payload=JSON.stringify([{id:'qa',title:'QA',body:mit}])+'\n'; + execFileSync('python3',['-c',`import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:z.writestr('assets/third_party_notices.json',sys.argv[2])`,path,payload]); + const result=inspectNoticeArchive(path,'assets/third_party_notices.json');assert.equal(result.payload.sha256,digest(payload));assert.equal(Buffer.from(result.payload.base64,'base64').toString(),payload); + for(const name of ['../private/LICENSE','/absolute/LICENSE','C:/private/LICENSE']){ + execFileSync('python3',['-c',`import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:z.writestr(sys.argv[2],'text')`,path,name]); + assert.throws(()=>inspectNoticeArchive(path)); + } + execFileSync('python3',['-W','ignore','-c',`import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:z.writestr('LICENSE','one');z.writestr('LICENSE','two')`,path]); + assert.throws(()=>inspectNoticeArchive(path)); +})); + +test('union of JS and autolinked native inputs cannot drop a NOTICE or accept conflicting bytes',()=>{ + const base={id:'npm:qa@1',name:'qa',version:'1',ecosystem:'npm',vendors:[],notices:[{source:'node_modules/qa/LICENSE',sha256:digest(mit),body:mit}]}; + const additional={...base,notices:[{source:'node_modules/qa/NOTICE',sha256:digest('extra'),body:'extra'}]}; + assert.equal(unionNoticeInputs([base],[additional])[0].notices.length,2); + assert.throws(()=>unionNoticeInputs([base],[{...base,notices:[{...base.notices[0],sha256:'0'.repeat(64)}]}])); +}); diff --git a/scripts/notice-archive.py b/scripts/notice-archive.py new file mode 100644 index 00000000..8bfe7c67 --- /dev/null +++ b/scripts/notice-archive.py @@ -0,0 +1,83 @@ +"""Read-only, bounded SDK/archive notice input. Never extracts files.""" +import base64 +import hashlib +import io +import json +import re +import sys +import zipfile +from pathlib import Path, PurePosixPath + +MAX_TEXT = 1024 * 1024 +MAX_TOTAL_TEXT = 16 * MAX_TEXT +MAX_NESTED = 128 * MAX_TEXT + + +def inspect_archive(path, payload_path=None, bundle_path=None): + result = {"notices": [], "nativeFiles": [], "payload": None, "bundle": None} + total = 0 + + def inspect(source, prefix="", depth=0): + nonlocal total + with zipfile.ZipFile(source) as archive: + entries = archive.infolist() + if len(entries) > 100000: + raise ValueError("Too many archive entries") + names = [entry.filename for entry in entries] + if len(set(names)) != len(names): + raise ValueError("Duplicate archive entry") + for entry in entries: + name = entry.filename + if name.startswith(("/", "\\")) or re.match(r"^[A-Za-z]:", name) or "\\" in name or ".." in PurePosixPath(name).parts: + raise ValueError("Unsafe archive entry") + if entry.is_dir(): + continue + logical = prefix + name + if payload_path and not prefix and name == payload_path: + if entry.file_size > MAX_TOTAL_TEXT: + raise ValueError("Oversized notice payload") + raw = archive.read(entry) + json.loads(raw) + result["payload"] = {"sha256": hashlib.sha256(raw).hexdigest(), "base64": base64.b64encode(raw).decode()} + if bundle_path and not prefix and name == bundle_path: + if not 0 < entry.file_size <= MAX_NESTED: + raise ValueError("Empty or oversized application bundle") + result["bundle"] = {"sha256": hashlib.sha256(archive.read(entry)).hexdigest(), "bytes": entry.file_size} + if any(re.fullmatch(r"(licen[sc]es?|notices?|copying|copyright|third[._-]?party[._-]?(licen[sc]es?|notices?))([._-].*)?", part, re.I) for part in PurePosixPath(name).parts): + if not 0 < entry.file_size <= MAX_TEXT: + raise ValueError("Empty or oversized notice") + total += entry.file_size + if total > MAX_TOTAL_TEXT: + raise ValueError("Too much notice text") + raw = archive.read(entry) + body = raw.decode("utf-8") + if not body.strip() or "\0" in body: + raise ValueError("Unreadable notice") + result["notices"].append({"source": logical, "body": body, "sha256": hashlib.sha256(raw).hexdigest()}) + if name.endswith(".so"): + if entry.file_size > MAX_NESTED: + raise ValueError("Oversized native input") + result["nativeFiles"].append({"path": logical, "sha256": hashlib.sha256(archive.read(entry)).hexdigest()}) + elif ".framework/" in name or name.endswith(".dylib"): + with archive.open(entry) as stream: + magic = stream.read(4) + if magic in [b"\xcf\xfa\xed\xfe", b"\xfe\xed\xfa\xcf", b"\xca\xfe\xba\xbe", b"\xbe\xba\xfe\xca"]: + if entry.file_size > MAX_NESTED: + raise ValueError("Oversized native input") + result["nativeFiles"].append({"path": logical, "sha256": hashlib.sha256(archive.read(entry)).hexdigest()}) + if depth == 0 and name.endswith(".jar"): + if entry.file_size > MAX_NESTED: + raise ValueError("Oversized nested SDK archive") + inspect(io.BytesIO(archive.read(entry)), logical + "!", depth + 1) + + inspect(path) + return result + + +if __name__ == "__main__": + try: + print(json.dumps(inspect_archive(Path(sys.argv[1]), *sys.argv[2:4]))) + except Exception: + # SDK/cache paths and arbitrary archive data never enter CI logs. + print("Notice archive inspection failed closed.", file=sys.stderr) + sys.exit(1) diff --git a/scripts/notices-runtime.init.gradle b/scripts/notices-runtime.init.gradle new file mode 100644 index 00000000..bfd78614 --- /dev/null +++ b/scripts/notices-runtime.init.gradle @@ -0,0 +1,45 @@ +// Discovery only: resolve selected release inputs, never compile or publish. +import groovy.json.JsonOutput +import java.security.MessageDigest +import org.gradle.api.artifacts.component.ModuleComponentIdentifier +import org.gradle.api.artifacts.component.ProjectComponentIdentifier + +gradle.projectsEvaluated { + def app = gradle.rootProject.findProject(':app') + if (app == null) throw new GradleException('Missing Android app project') + app.tasks.register('writeNoticeRuntimeInputs') { + doLast { + def requested = app.findProperty('noticeInputsOutput') + if (!requested) throw new GradleException('Explicit private notice output required') + def configuration = app.configurations.getByName('releaseRuntimeClasspath') + def artifacts = configuration.resolvedConfiguration.resolvedArtifacts + def metadataOnly = [] + def components = configuration.incoming.resolutionResult.allComponents.collect { component -> + def id = component.id + if (id instanceof ModuleComponentIdentifier) { + def files = artifacts.findAll { it.id.componentIdentifier == id }.collect { artifact -> + def sum = MessageDigest.getInstance('SHA-256') + artifact.file.withInputStream { stream -> + byte[] buffer = new byte[65536]; int count + while ((count = stream.read(buffer)) != -1) sum.update(buffer, 0, count) + } + [file: artifact.file.absolutePath, sha256: sum.digest().encodeHex().toString()] + } + // BOM/constraint metadata contributes no packaged artifact. + if (files.isEmpty()) { metadataOnly.add([group: id.group, name: id.module, version: id.version]); return null } + return [group: id.group, name: id.module, version: id.version, artifacts: files] + } + if (id instanceof ProjectComponentIdentifier && id.projectPath != ':app') { + def project = gradle.rootProject.findProject(id.projectPath) + if (project == null) throw new GradleException('Unresolved native project input') + return [projectBuildFile: project.buildFile.absolutePath] + } + if (id instanceof ProjectComponentIdentifier && id.projectPath == ':app') return null + throw new GradleException('Unknown release component input') + }.findAll { it != null } + def output = new File(requested.toString()) + output.parentFile.mkdirs() + output.text = JsonOutput.toJson([schema: 1, configuration: 'releaseRuntimeClasspath', components: components, metadataOnly: metadataOnly]) + } + } +} diff --git a/src/components/FontIconLicenses.tsx b/src/components/FontIconLicenses.tsx index 932efebe..2df152c5 100644 --- a/src/components/FontIconLicenses.tsx +++ b/src/components/FontIconLicenses.tsx @@ -4,10 +4,12 @@ import {AccessibilityInfo,Modal,Platform,ScrollView,StyleSheet,Text,View} from ' import {SafeAreaView} from 'react-native-safe-area-context'; import {Pressable} from './TelemetryPressable'; import {SettingsRow} from './SettingsRow'; -import notices from '../font-icon-notices.json'; +import notices from '../../assets/notices/third_party_notices.json'; import {useLocalization} from '../localization-context'; import {headingStyle,spacing,type Theme} from '../theme'; +const sections:readonly {id:string;title:string;body:string;androidOnly?:boolean;iosOnly?:boolean}[]=notices; + export function FontIconLicenses({theme}:{theme:Theme}) { const {t}=useLocalization(),[visible,setVisible]=useState(false),heading=useRef(null),trigger=useRef(null); const close=()=>setVisible(false); @@ -24,7 +26,7 @@ export function FontIconLicenses({theme}:{theme:Theme}) { - {notices.filter(section=>!section.androidOnly||Platform.OS==='android').map(section=> + {sections.filter(section=>(!section.androidOnly||Platform.OS==='android')&&(!section.iosOnly||Platform.OS==='ios')).map(section=> {section.title} {section.body} )} diff --git a/src/font-icon-notices.test.ts b/src/font-icon-notices.test.ts index f7ebfaf0..918bc5c7 100644 --- a/src/font-icon-notices.test.ts +++ b/src/font-icon-notices.test.ts @@ -7,7 +7,8 @@ const root=new URL('../',import.meta.url); const read=(path:string)=>readFileSync(new URL(path,root)); const hash=(value:Uint8Array|string)=>createHash('sha256').update(value).digest('hex'); const provenance=JSON.parse(read('scripts/font-icon-provenance.json').toString()); -const notices=JSON.parse(read('src/font-icon-notices.json').toString()) as {id:string;body:string;androidOnly?:boolean}[]; +const notices=(JSON.parse(read('assets/notices/third_party_notices.json').toString()) as {id:string;body:string;androidOnly?:boolean}[]) + .filter(section=>Object.hasOwn(provenance.notices,section.id)); test('font bytes, glyphs, pinned dependencies and verbatim license copies require deliberate notice review on change',()=>{ const lock=JSON.parse(read('package-lock.json').toString()); From b67532b32db187ab861e0644008e910ab2078cd2 Mon Sep 17 00:00:00 2001 From: Patrick Chin Date: Sun, 15 Nov 2026 04:02:16 +0800 Subject: [PATCH 05/37] Install emulator host runtime and diagnose loader dependencies (#215) * Install emulator host runtime and diagnose missing libraries * Keep SDK ELF diagnostics advisory to the real launcher --------- Co-authored-by: Patrick Chin <1392122+patrickchin@users.noreply.github.com> --- .github/workflows/android-signing-proof.yml | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/android-signing-proof.yml b/.github/workflows/android-signing-proof.yml index f5ac7556..6b3ce9b3 100644 --- a/.github/workflows/android-signing-proof.yml +++ b/.github/workflows/android-signing-proof.yml @@ -76,8 +76,33 @@ jobs: timeout 30 sdkmanager --version | sed -n '1,4p' timeout 30 adb version | sed -n '1,4p' sdkmanager 'emulator' 'build-tools;36.0.0' 'system-images;android-36;google_apis;x86_64' + sudo apt-get update + sudo apt-get install --yes --no-install-recommends libpulse0 + dpkg-query -W libpulse0 + for puptime_properties in "$ANDROID_HOME/emulator/source.properties" "$ANDROID_HOME/build-tools/36.0.0/source.properties" "$ANDROID_HOME/system-images/android-36/google_apis/x86_64/source.properties"; do + test -f "$puptime_properties" + printf 'Android package metadata: %s\n' "$puptime_properties" + awk '/^Pkg\.Revision[[:space:]]*=/ {revision=1} /^(Pkg\.(Desc|Revision|BuildId)|AndroidVersion\.ApiLevel)[[:space:]]*=/ && ++n<=4 {print} END {exit !revision}' "$puptime_properties" + done test -x "$ANDROID_HOME/emulator/emulator" printf 'Android executable: %s\n' "$ANDROID_HOME/emulator/emulator" + # The launcher may supply bundled SDK library paths; ldd is diagnostic only. + puptime_dependency_report="$PUPTIME_SIGNING_PRIVATE/loader-dependencies.txt" + : > "$puptime_dependency_report" + puptime_loader_failed=0 + for puptime_binary in "$ANDROID_HOME/emulator/emulator" "$ANDROID_HOME/emulator/qemu/linux-x86_64/qemu-system-x86_64"; do + printf 'Checking host dependencies: %s\n' "$puptime_binary" + test -x "$puptime_binary" + timeout 30 ldd "$puptime_binary" >> "$puptime_dependency_report" 2>&1 || puptime_loader_failed=1 + done + puptime_missing_libraries=$(awk '$2=="=>" && $3=="not" && $4=="found" {print $1}' "$puptime_dependency_report" | sort -u) + if [[ -n "$puptime_missing_libraries" || "$puptime_loader_failed" != 0 ]]; then + printf 'Unresolved emulator ELF libraries (diagnostic; launcher may supply SDK paths):\n%s\n' "$puptime_missing_libraries" + if [[ "$puptime_loader_failed" != 0 ]]; then + printf 'ELF inspection diagnostic; launcher and boot checks remain mandatory:\n' + sed -n '1,40p' "$puptime_dependency_report" + fi + fi timeout 30 emulator -version | sed -n '1,4p' export ANDROID_AVD_HOME="$PUPTIME_SIGNING_PRIVATE/avd" mkdir -m 700 "$ANDROID_AVD_HOME" From ee314377b255b6c6b850c96f2d2e065e46ed4c70 Mon Sep 17 00:00:00 2001 From: Patrick Chin Date: Sun, 15 Nov 2026 04:04:41 +0800 Subject: [PATCH 06/37] Admit compiled iOS hosts explicitly for isolated tests (#216) Co-authored-by: Patrick Chin <1392122+patrickchin@users.noreply.github.com> --- .github/workflows/checks.yml | 1 + .github/workflows/ios-simulator.yml | 31 +++++++++------ docs/native-account-e2e.md | 43 +++++++++++++++++++++ scripts/native-host-policy.mjs | 50 +++++++++++++++++++++++++ scripts/native-host-policy.test.mjs | 45 ++++++++++++++++++++++ scripts/run-hosted-ios-account-case.mjs | 24 +++++++----- scripts/verify-ios-runtime-upgrade.mjs | 2 +- 7 files changed, 174 insertions(+), 22 deletions(-) create mode 100644 scripts/native-host-policy.mjs create mode 100644 scripts/native-host-policy.test.mjs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index c269b49d..c977268a 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -15,6 +15,7 @@ jobs: cache: npm - run: npm ci - run: node --test scripts/native-task-cleanup.test.mjs + - run: node --test scripts/native-host-policy.test.mjs - run: npm run typecheck - run: npm run typecheck:ios - run: npm test diff --git a/.github/workflows/ios-simulator.yml b/.github/workflows/ios-simulator.yml index 62043a0e..0587be6d 100644 --- a/.github/workflows/ios-simulator.yml +++ b/.github/workflows/ios-simulator.yml @@ -13,9 +13,14 @@ on: options: [full, accounts] default: full native_host_run_id: - description: Successful main iOS run supplying the unchanged native host + description: Main iOS run supplying the unchanged native host (full success by default) type: string default: '43383649287' + native_host_policy: + description: Test host admission; compiled-for-testing never qualifies a release + type: choice + options: [full-suite-success, compiled-for-testing] + default: full-suite-success account_case: description: Isolated account case type: choice @@ -66,6 +71,13 @@ jobs: build/ios-simulator/Build/Products/Release-iphonesimulator/Puptime.app \ build/Puptime-iOS-Simulator.zip unzip -tq build/Puptime-iOS-Simulator.zip + - name: Upload compiled iOS host for testing + uses: actions/upload-artifact@v6 + with: + name: puptime-ios-simulator + path: build/Puptime-iOS-Simulator.zip + if-no-files-found: error + retention-days: 7 - name: Install Maestro env: MAESTRO_VERSION: "2.11.0" @@ -76,7 +88,7 @@ jobs: timeout-minutes: 60 env: MAESTRO_CLI_NO_ANALYTICS: "1" - MAESTRO_DRIVER_STARTUP_TIMEOUT: "180000" + MAESTRO_DRIVER_STARTUP_TIMEOUT: "300000" run: | runtime_id=$(xcrun simctl list runtimes -j | jq -r '[.runtimes[] | select(.isAvailable and (.identifier | contains("iOS-26")))][-1].identifier // empty') test -n "$runtime_id" @@ -98,7 +110,7 @@ jobs: env: CI: "1" MAESTRO_CLI_NO_ANALYTICS: "1" - MAESTRO_DRIVER_STARTUP_TIMEOUT: "180000" + MAESTRO_DRIVER_STARTUP_TIMEOUT: "300000" run: | mkdir -p build/onboarding-ci ditto build/ios-simulator/Build/Products/Release-iphonesimulator/Puptime.app build/onboarding-ci/Puptime.app @@ -133,7 +145,7 @@ jobs: env: CI: "1" MAESTRO_CLI_NO_ANALYTICS: "1" - MAESTRO_DRIVER_STARTUP_TIMEOUT: "180000" + MAESTRO_DRIVER_STARTUP_TIMEOUT: "300000" run: | runtime_id=$(xcrun simctl list runtimes -j | jq -r '[.runtimes[] | select(.isAvailable and (.identifier | contains("iOS-26")))][-1].identifier // empty') test -n "$runtime_id" @@ -188,12 +200,6 @@ jobs: capture_screenshot "$iphone_id" build/Puptime-iPhone.png capture_screenshot "$ipad_id" build/Puptime-iPad.png - - uses: actions/upload-artifact@v6 - with: - name: puptime-ios-simulator - path: build/Puptime-iOS-Simulator.zip - if-no-files-found: error - retention-days: 7 - uses: actions/upload-artifact@v6 with: name: puptime-app-store-screenshots @@ -237,9 +243,10 @@ jobs: env: GH_TOKEN: ${{ github.token }} PUPTIME_NATIVE_HOST_RUN_ID: ${{ inputs.native_host_run_id }} + PUPTIME_NATIVE_HOST_POLICY: ${{ inputs.native_host_policy }} PUPTIME_ACCOUNT_SCENARIO: ${{ matrix.scenario }} MAESTRO_CLI_NO_ANALYTICS: '1' - MAESTRO_DRIVER_STARTUP_TIMEOUT: '180000' + MAESTRO_DRIVER_STARTUP_TIMEOUT: '300000' run: node scripts/run-hosted-ios-account-case.mjs - name: Upload bounded synthetic account receipts if: always() @@ -284,7 +291,7 @@ jobs: GH_TOKEN: ${{ github.token }} UPGRADE_STAGE: ${{ inputs.upgrade_stage }} MAESTRO_CLI_NO_ANALYTICS: '1' - MAESTRO_DRIVER_STARTUP_TIMEOUT: '180000' + MAESTRO_DRIVER_STARTUP_TIMEOUT: '300000' run: | case "$UPGRADE_STAGE" in inspect) node scripts/verify-ios-runtime-upgrade.mjs --inspect-only build/native-runtime-replacement ;; diff --git a/docs/native-account-e2e.md b/docs/native-account-e2e.md index 1f8d908c..0e5440ae 100644 --- a/docs/native-account-e2e.md +++ b/docs/native-account-e2e.md @@ -4,6 +4,49 @@ Use the real iOS app and native BetterAuth SDK with the ephemeral local HTTPS se The ordinary pull-request workflow retains its full build and test path. Manual account runs can execute independently of the standard UI suite, reusing a successful main build whose native inputs match the selected source ref. Archive digests, native signatures, bundle identity and native-input checks fail closed. +## Compiled hosts are test inputs, not release qualification + +The default `native_host_policy=full-suite-success` still requires a completed, +successful main/push iOS workflow. There is no automatic fallback when that run +fails. The explicit `compiled-for-testing` policy admits only a trusted main/push +run whose actual native compile and early test-host upload steps both succeeded +in the selected run attempt. This permits more isolated tests to run while the +full suite is pending or red; those suite results remain visible and required +for release qualification. + +The producer uploads the same single simulator ZIP with its existing seven-day +retention immediately after successful compile/ZIP validation. Consumer checks +retain exact native-input equality, artifact nonexpiry/whole digest, deep strict +signatures, arm64 and fingerprints/signature verification of every embedded +Mach-O. The upload must be unique and its timestamps must fall inside this +attempt's successful upload step (a one-second envelope accounts for observed +GitHub timestamp precision). Duplicate or stale artifacts fail closed; no +artifact overwrite or deletion is introduced. The consumer rechecks run attempt, +source and artifact metadata after download, before creating a simulator. + +Use the additional policy only deliberately: + +```sh +gh workflow run ios-simulator.yml --ref \ + -f suite=accounts -f account_case= \ + -f native_host_run_id= \ + -f native_host_policy=compiled-for-testing +``` + +`native-provenance.json` records the admission policy, actual observed full-run +status/conclusion and selected attempt, alongside native hashes. Its +`releaseQualified` is always false: a passing isolated test or a compiled +simulator host is not a release certificate. Full standard/conversational, +SQLite-fault, SDK, deletion and final candidate/provider/store gates remain +separate requirements. Read the observation timestamp rather than treating an +in-progress snapshot as the workflow's eventual result. + +The iOS Maestro **driver startup** bound is 300 seconds after a measured 180-second +startup expiry occurred just before the driver's HTTP server became available. +Application assertions, flow/command/overall deadlines, Android and private +authentication handoff timers are unchanged. Original-runtime verification honors +a supplied driver bound instead of forcing 180 seconds. + ## Case selection | Manual case | Evidence | diff --git a/scripts/native-host-policy.mjs b/scripts/native-host-policy.mjs new file mode 100644 index 00000000..1ebdb022 --- /dev/null +++ b/scripts/native-host-policy.mjs @@ -0,0 +1,50 @@ +// Metadata admission only. Download digest, native inputs and signed code are +// independently checked by the caller before creating a simulator. +import assert from 'node:assert/strict'; + +export const hostPolicies=['full-suite-success','compiled-for-testing']; +export const compileStep='Build app and widget for iOS Simulator'; +export const testHostUploadStep='Upload compiled iOS host for testing'; +const stamp=value=>{assert.match(value??'',/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/);const time=Date.parse(value);assert.ok(Number.isFinite(time));assert.equal(new Date(time).toISOString().replace('.000Z','Z'),value);return time;}; +const positive=value=>{assert.ok(Number.isSafeInteger(value)&&value>0);return value;}; + +export function assertNativeHostRun(run,policy='full-suite-success',repository='patrickchin/puptime'){ + assert.ok(hostPolicies.includes(policy),'Choose an explicit supported test-host policy.'); + positive(run.id);positive(run.run_attempt); + assert.equal(repository,'patrickchin/puptime');assert.equal(run.repository?.full_name,repository);assert.equal(run.head_repository?.full_name,repository); + assert.equal(run.head_branch,'main');assert.equal(run.event,'push');assert.equal(run.path,'.github/workflows/ios-simulator.yml');assert.match(run.head_sha??'',/^[a-f0-9]{40}$/); + if(policy==='full-suite-success'){ + assert.equal(run.status,'completed');assert.equal(run.conclusion,'success','Default hosts require the entire workflow to succeed.'); + }else{ + assert.ok(['in_progress','completed'].includes(run.status)); + if(run.status==='in_progress')assert.equal(run.conclusion,null);else assert.ok(['success','failure','timed_out','cancelled'].includes(run.conclusion)); + } +} +export function assertNativeHostPolicy({policy='full-suite-success',run,artifacts,jobs,repository='patrickchin/puptime',now=Date.now()}){ + assertNativeHostRun(run,policy,repository); + assert.equal(artifacts.total_count,artifacts.artifacts.length,'Do not accept a truncated artifact listing.'); + const matches=artifacts.artifacts.filter(value=>value.name==='puptime-ios-simulator');assert.equal(matches.length,1); + const artifact=matches[0];positive(artifact.id);positive(artifact.size_in_bytes);assert.ok(artifact.size_in_bytes<=256*1024*1024); + assert.equal(artifact.expired,false);assert.ok(stamp(artifact.expires_at)>now);assert.match(artifact.digest??'',/^sha256:[a-f0-9]{64}$/); + assert.equal(artifact.workflow_run?.id,run.id);assert.equal(artifact.workflow_run?.head_sha,run.head_sha);assert.equal(artifact.workflow_run?.head_branch,'main'); + let build; + if(policy==='compiled-for-testing'){ + assert.equal(jobs.total_count,jobs.jobs.length,'Use all jobs from the explicitly selected attempt.'); + const matches=jobs.jobs.filter(value=>value.name==='build');assert.equal(matches.length,1);build=matches[0]; + positive(build.id);assert.equal(build.run_id,run.id);assert.equal(build.run_attempt,run.run_attempt);assert.equal(build.head_sha,run.head_sha); + const step=name=>{const values=build.steps.filter(value=>value.name===name);assert.equal(values.length,1);const value=values[0];assert.equal(value.status,'completed');assert.equal(value.conclusion,'success');positive(value.number);assert.ok(stamp(value.completed_at)>=stamp(value.started_at));return value;}; + const compile=step(compileStep),upload=step(testHostUploadStep); + assert.ok(upload.number>compile.number&&stamp(upload.started_at)>=stamp(compile.completed_at)); + // Actual GitHub metadata rounded artifact creation one second later than the + // upload step's completion. This one-second precision envelope is bounded; + // an earlier attempt's artifact cannot fall inside this upload window. + const start=stamp(upload.started_at)-1000,end=stamp(upload.completed_at)+1000; + const created=stamp(artifact.created_at),updated=stamp(artifact.updated_at); + assert.ok(created>=start&&created<=end&&updated>=created&&updated<=end,'Artifact must belong to this attempt and upload time window.'); + } + return {artifact,proof:{hostQualification:policy,releaseQualified:false,nativeHostRunAttempt:run.run_attempt,hostRunObservedAt:new Date(now).toISOString(),hostRunStatus:run.status,hostRunConclusion:run.conclusion,...(build?{nativeHostBuildJobId:build.id}:{})}}; +} + +export function assertSameHostAttempt(first,current){ + for(const key of ['id','run_attempt','head_sha'])assert.equal(current[key],first[key],'Host attempt/source changed while downloading; select it again explicitly.'); +} diff --git a/scripts/native-host-policy.test.mjs b/scripts/native-host-policy.test.mjs new file mode 100644 index 00000000..1fdece4e --- /dev/null +++ b/scripts/native-host-policy.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {assertNativeHostPolicy,assertSameHostAttempt,compileStep,testHostUploadStep} from './native-host-policy.mjs'; +const now=Date.parse('2026-11-15T00:10:00Z'); +function fixture(){ + const run={id:123,run_attempt:2,repository:{full_name:'patrickchin/puptime'},head_repository:{full_name:'patrickchin/puptime'},head_branch:'main',event:'push',path:'.github/workflows/ios-simulator.yml',head_sha:'a'.repeat(40),status:'completed',conclusion:'success'}; + const artifact={id:456,name:'puptime-ios-simulator',size_in_bytes:1024,expired:false,digest:'sha256:'+'b'.repeat(64),created_at:'2026-11-15T00:05:02Z',updated_at:'2026-11-15T00:05:10Z',expires_at:'2026-11-22T00:05:02Z',workflow_run:{id:123,head_sha:run.head_sha,head_branch:'main'}}; + const step=(name,number,started_at,completed_at)=>({name,number,started_at,completed_at,status:'completed',conclusion:'success'}); + const jobs={total_count:1,jobs:[{id:789,name:'build',run_id:123,run_attempt:2,head_sha:run.head_sha,status:'in_progress',steps:[step(compileStep,9,'2026-11-15T00:00:00Z','2026-11-15T00:05:00Z'),step(testHostUploadStep,10,'2026-11-15T00:05:01Z','2026-11-15T00:05:10Z')]}]}; + return {run,artifacts:{total_count:1,artifacts:[artifact]},jobs,now}; +} +const rejects=(change,policy='compiled-for-testing')=>{const value=fixture();change(value);assert.throws(()=>assertNativeHostPolicy({...value,policy}));}; + +test('default policy still requires full workflow success and never falls back',()=>{ + const value=fixture(),before=structuredClone(value),result=assertNativeHostPolicy(value);assert.equal(result.proof.hostQualification,'full-suite-success');assert.equal(result.proof.releaseQualified,false);assert.deepEqual(value,before); + for(const conclusion of ['failure','cancelled','timed_out',null])rejects(value=>{value.run.conclusion=conclusion;},'full-suite-success'); + rejects(value=>{value.run.status='in_progress';value.run.conclusion=null;},'full-suite-success'); + assert.throws(()=>assertNativeHostPolicy({...value,policy:'allow-failed'})); +}); + +test('explicit compiled policy permits more tests while preserving actual red or pending full-suite state',()=>{ + for(const [status,conclusion] of [['completed','success'],['completed','failure'],['completed','timed_out'],['completed','cancelled'],['in_progress',null]]){ + const value=fixture();Object.assign(value.run,{status,conclusion});const proof=assertNativeHostPolicy({...value,policy:'compiled-for-testing'}).proof; + assert.equal(proof.hostQualification,'compiled-for-testing');assert.equal(proof.releaseQualified,false);assert.equal(proof.hostRunStatus,status);assert.equal(proof.hostRunConclusion,conclusion);assert.equal(proof.nativeHostRunAttempt,2);assert.equal(proof.nativeHostBuildJobId,789); + } + rejects(value=>{value.run.status='queued';});rejects(value=>{value.run.conclusion='unknown';}); +}); + +test('trusted source, complete metadata and immutable artifact identity are required',()=>{ + for(const change of [v=>v.run.head_branch='feature',v=>v.run.event='pull_request',v=>v.run.path='other.yml',v=>v.run.repository.full_name='other/repo',v=>v.run.head_repository.full_name='fork/repo',v=>v.run.head_sha='invalid',v=>v.artifacts.total_count++,v=>v.artifacts.artifacts.push(structuredClone(v.artifacts.artifacts[0])),v=>v.artifacts.artifacts[0].expired=true,v=>v.artifacts.artifacts[0].expires_at='2026-11-14T00:00:00Z',v=>v.artifacts.artifacts[0].expires_at='2027-02-31T00:00:00Z',v=>v.artifacts.artifacts[0].digest='unknown',v=>v.artifacts.artifacts[0].workflow_run.id++,v=>v.artifacts.artifacts[0].workflow_run.head_sha='c'.repeat(40),v=>v.artifacts.artifacts[0].workflow_run.head_branch='feature',v=>v.artifacts.artifacts[0].size_in_bytes=0,v=>v.artifacts.artifacts[0].size_in_bytes=257*1024*1024])rejects(change); +}); + +test('only unique successful compile/upload steps in the selected attempt can qualify',()=>{ + for(const change of [v=>v.jobs.total_count++,v=>v.jobs.jobs.push(structuredClone(v.jobs.jobs[0])),v=>v.jobs.jobs[0].run_attempt--,v=>v.jobs.jobs[0].run_id++,v=>v.jobs.jobs[0].head_sha='c'.repeat(40),v=>v.jobs.jobs[0].steps.pop(),v=>v.jobs.jobs[0].steps.push(structuredClone(v.jobs.jobs[0].steps[0])),v=>v.jobs.jobs[0].steps[0].conclusion='failure',v=>v.jobs.jobs[0].steps[1].conclusion='skipped',v=>v.jobs.jobs[0].steps[1].status='in_progress',v=>v.jobs.jobs[0].steps[1].number=8,v=>v.jobs.jobs[0].steps[1].started_at='2026-11-15T00:04:59Z'])rejects(change); +}); + +test('stale earlier-attempt artifacts fail; only the observed one-second metadata envelope is allowed',()=>{ + for(const change of [v=>v.artifacts.artifacts[0].created_at='2026-11-14T23:59:59Z',v=>v.artifacts.artifacts[0].updated_at='2026-11-15T00:05:12Z',v=>v.artifacts.artifacts[0].created_at='2026-11-15T00:05:12Z',v=>v.artifacts.artifacts[0].updated_at='2026-11-15T00:05:01Z'])rejects(change); + const value=fixture();value.artifacts.artifacts[0].created_at=value.artifacts.artifacts[0].updated_at='2026-11-15T00:05:11Z';assertNativeHostPolicy({...value,policy:'compiled-for-testing'}); +}); + +test('a new attempt/source during download must be explicitly selected again',()=>{ + const {run}=fixture();assertSameHostAttempt(run,structuredClone(run)); + for(const key of ['id','run_attempt','head_sha']){const changed=structuredClone(run);changed[key]=key==='head_sha'?'d'.repeat(40):changed[key]+1;assert.throws(()=>assertSameHostAttempt(run,changed));} +}); diff --git a/scripts/run-hosted-ios-account-case.mjs b/scripts/run-hosted-ios-account-case.mjs index e6129b02..c37b3afa 100644 --- a/scripts/run-hosted-ios-account-case.mjs +++ b/scripts/run-hosted-ios-account-case.mjs @@ -6,6 +6,8 @@ import {existsSync,mkdirSync,mkdtempSync,readFileSync,realpathSync,statSync,writ import {tmpdir} from 'node:os'; import {dirname,isAbsolute,join,resolve} from 'node:path'; import {deleteOwnedSimulator,installTaskCleanup,ownProcess} from './native-task-cleanup.mjs'; +import {nativeCodeFingerprint} from './native-runtime-inputs.mjs'; +import {assertNativeHostRun,assertNativeHostPolicy,assertSameHostAttempt} from './native-host-policy.mjs'; const repository=process.env.GITHUB_REPOSITORY,runId=process.env.PUPTIME_NATIVE_HOST_RUN_ID; assert.equal(repository,'patrickchin/puptime','Run only in the intended repository.'); @@ -18,23 +20,21 @@ const command=(binary,args,options={})=>execFileSync(binary,args,{encoding:'utf8 const api=path=>JSON.parse(command('gh',['api',`repos/${repository}/${path}`])); const source=command('git',['rev-parse','HEAD']); const run=api(`actions/runs/${runId}`); -assert.equal(run.status,'completed');assert.equal(run.conclusion,'success'); -assert.equal(run.head_repository?.full_name,repository);assert.equal(run.head_branch,'main'); -assert.equal(run.event,'push','Use a main push artifact; a pull-request head is not its compiled merge checkout.'); -assert.equal(run.path,'.github/workflows/ios-simulator.yml'); -assert.match(run.head_sha,/^[a-f0-9]{40}$/); +const policy=process.env.PUPTIME_NATIVE_HOST_POLICY??'full-suite-success'; +assertNativeHostRun(run,policy,repository); command('git',['fetch','--no-tags','--depth=1','origin',run.head_sha]); // These produce the compiled native host. Candidate JavaScript is re-bundled by // the existing account runner, so it is identified separately in the receipt. const nativeInputs=['package.json','package-lock.json','app.json','app.config.*','eas.json','.npmrc','plugins','patches','assets','ios']; const changedNativeInputs=command('git',['diff','--name-only',run.head_sha,source,'--',...nativeInputs]); assert.equal(changedNativeInputs,'','Native inputs changed: use a newly qualified native host.'); -const artifacts=api(`actions/runs/${runId}/artifacts`).artifacts.filter(artifact=>artifact.name==='puptime-ios-simulator'); -assert.equal(artifacts.length,1);const artifact=artifacts[0];assert.equal(artifact.expired,false); -assert.match(artifact.digest??'',/^sha256:[a-f0-9]{64}$/); +const artifacts=api(`actions/runs/${runId}/artifacts?per_page=100`); +const jobs=policy==='compiled-for-testing'?api(`actions/runs/${runId}/attempts/${run.run_attempt}/jobs?per_page=100`):undefined; +const {artifact}=assertNativeHostPolicy({policy,run,artifacts,jobs,repository}); const archive=process.env.PUPTIME_NATIVE_HOST_ARCHIVE?readFileSync(resolve(process.env.PUPTIME_NATIVE_HOST_ARCHIVE)) :execFileSync('gh',['api',`repos/${repository}/actions/artifacts/${artifact.id}/zip`],{timeout:180000,maxBuffer:256*1024*1024,stdio:['ignore','pipe','pipe']}); assert.equal('sha256:'+createHash('sha256').update(archive).digest('hex'),artifact.digest,'Verify the downloaded artifact bytes.'); +assert.equal(archive.length,artifact.size_in_bytes); const archivePath=join(privateDirectory,'host-artifact.zip');writeFileSync(archivePath,archive); command('unzip',['-q',archivePath,'-d',privateDirectory]); const appArchive=join(privateDirectory,'Puptime-iOS-Simulator.zip');assert.ok(existsSync(appArchive)); @@ -44,7 +44,13 @@ command('codesign',['--verify','--deep','--strict',host]); assert.equal(command('plutil',['-extract','CFBundleIdentifier','raw','-o','-',join(host,'Info.plist')]),'com.patrickchin.puptime'); assert.match(command('lipo',['-archs',join(host,'Puptime')]),/\barm64\b/); const hash=path=>createHash('sha256').update(readFileSync(path)).digest('hex'); -const provenance={scenario,candidateSource:source,nativeSource:run.head_sha,nativeHostRunId:runId,artifactId:artifact.id,artifactDigest:artifact.digest,archiveSource:process.env.PUPTIME_NATIVE_HOST_ARCHIVE?'verified-local-cache':'download',nativeInputsUnchanged:true,nativeExecutableSHA256:hash(join(host,'Puptime')),scope:scenario==='enrolled-upgrade'?'Driver source with privately prepared historical native/source inputs; the successful-main artifact is a reference attestation, and the prepared older native host is installed first. Actual native email SDK and isolated local HTTPS service; controlled-origin runtime qualification only.':'Actual native email SDK + ephemeral local HTTPS Better Auth/D1/SQLite DO; synthetic identity and history only; no production or provider OAuth qualification.'}; +const nativeCode=nativeCodeFingerprint(host,mkdtempSync(join(privateDirectory,'native-code-')));assert.equal(nativeCode.Puptime?.fileSHA256,hash(join(host,'Puptime'))); +for(const name of Object.keys(nativeCode))command('codesign',['--verify','--strict',join(host,name)]); +// A rerun must not silently substitute an older attempt while bytes download. +const currentRun=api(`actions/runs/${runId}`);assertSameHostAttempt(run,currentRun); +const currentArtifact=api(`actions/artifacts/${artifact.id}`);assert.equal(currentArtifact.digest,artifact.digest); +const {proof}=assertNativeHostPolicy({policy,run:currentRun,artifacts:{total_count:1,artifacts:[currentArtifact]},jobs,repository}); +const provenance={...proof,scenario,candidateSource:source,nativeSource:run.head_sha,nativeHostRunId:runId,artifactId:artifact.id,artifactDigest:artifact.digest,archiveSource:process.env.PUPTIME_NATIVE_HOST_ARCHIVE?'verified-local-cache':'download',nativeInputsUnchanged:true,nativeExecutableSHA256:hash(join(host,'Puptime')),nativeCode,scope:scenario==='enrolled-upgrade'?'Driver source with privately prepared historical native/source inputs; the selected-main artifact is a reference attestation, and the prepared older native host is installed first. Actual native email SDK and isolated local HTTPS service; controlled-origin runtime qualification only.':'Actual native email SDK + ephemeral local HTTPS Better Auth/D1/SQLite DO; synthetic identity and history only; no production, provider OAuth or release qualification.'}; let initialHost=host; if(scenario==='enrolled-upgrade'){ const path=process.env.PUPTIME_ENROLLED_RUNTIME_INPUTS;assert.ok(path&&isAbsolute(path));assert.equal(realpathSync(path),path);assert.equal(statSync(path).mode&0o777,0o600);assert.equal(statSync(dirname(path)).mode&0o777,0o700);assert.ok(statSync(path).size<2097152); diff --git a/scripts/verify-ios-runtime-upgrade.mjs b/scripts/verify-ios-runtime-upgrade.mjs index 13a991ed..5723efac 100644 --- a/scripts/verify-ios-runtime-upgrade.mjs +++ b/scripts/verify-ios-runtime-upgrade.mjs @@ -187,7 +187,7 @@ async function verify(){ const path=join(privateDir,name+'.yaml'),artifacts=join(privateDir,'maestro-'+name),receipt=join(output,name);let passed=false; writeFileSync(path,`appId: ${app}\nname: ${name}\n---\n${body}`); try{ - await runCommand(process.env.MAESTRO_BIN??'maestro',['--device',device,'test',path,'--test-output-dir',artifacts],600000,{...process.env,MAESTRO_CLI_NO_ANALYTICS:'1',MAESTRO_DRIVER_STARTUP_TIMEOUT:'180000'});passed=true; + await runCommand(process.env.MAESTRO_BIN??'maestro',['--device',device,'test',path,'--test-output-dir',artifacts],600000,{...process.env,MAESTRO_CLI_NO_ANALYTICS:'1',MAESTRO_DRIVER_STARTUP_TIMEOUT:process.env.MAESTRO_DRIVER_STARTUP_TIMEOUT??'300000'});passed=true; }finally{ // This guest-only exercise has no auth secrets. Keep raw driver logs private; // the three-day CI receipt needs only bounded synthetic visual evidence. From f812aa8ca974756ca396ff6dabaa6fecf09d3fb0 Mon Sep 17 00:00:00 2001 From: Patrick Chin Date: Sun, 15 Nov 2026 04:16:39 +0800 Subject: [PATCH 07/37] Project private sign-out readiness flags before confirmation (#217) Co-authored-by: Patrick Chin <1392122+patrickchin@users.noreply.github.com> --- .maestro/account-preservation/sign-out.yaml | 4 ++++ scripts/native-account-evidence.mjs | 15 +++++++++++---- scripts/native-account-evidence.test.mjs | 20 ++++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.maestro/account-preservation/sign-out.yaml b/.maestro/account-preservation/sign-out.yaml index a2de3ca4..55cf6ede 100644 --- a/.maestro/account-preservation/sign-out.yaml +++ b/.maestro/account-preservation/sign-out.yaml @@ -19,8 +19,12 @@ name: Account preservation sign-out id: account.signOut direction: DOWN - waitForAnimationToEnd +- assertVisible: + id: account.signOut + enabled: true - tapOn: id: account.signOut + enabled: true retryTapIfNoChange: true - assertVisible: ^Sign out on this device\?$ - waitForAnimationToEnd diff --git a/scripts/native-account-evidence.mjs b/scripts/native-account-evidence.mjs index d3187ab5..56ef7e47 100644 --- a/scripts/native-account-evidence.mjs +++ b/scripts/native-account-evidence.mjs @@ -125,9 +125,14 @@ export function capturedJson(body,contentEncoding){ // Project only allowlisted readiness flags; never retain input values or labels. export function nativeAuthUiFlags(hierarchy,{email='',code=''}={}){ - const fields=Object.fromEntries(['account.email.input','account.code.input','account.sendCode','account.verify','account.refresh','account.error','account.identity'].map(id=>[id,{present:false}])); - function visit(node){ + const fields=Object.fromEntries(['account.email.input','account.code.input','account.sendCode','account.verify','account.refresh','account.error','account.identity','account.signOut'].map(id=>[id,{present:false}])); + const copies=[['Sign out on this device?','Cancel','Sign out'],['¿Cerrar sesión en este dispositivo?','Cancelar','Cerrar sesión'],['在这台设备上退出登录?','取消','退出登录']]; + const observed=copies.map(()=>[false,false,false]); + function visit(node,visible=true,insideSignOut=false){ const a=node?.attributes??{},id=a['resource-id']; + visible=visible&&a.visible!==false&&a.visible!=='false'&&a.displayed!==false&&a.displayed!=='false'; + insideSignOut=insideSignOut||id==='account.signOut'; + if(visible&&!insideSignOut)copies.forEach((copy,i)=>copy.forEach((text,j)=>{if(a.text===text||a.accessibilityText===text)observed[i][j]=true;})); if(Object.hasOwn(fields,id)){ const flags={present:true,enabled:a.enabled===true||a.enabled==='true',focused:a.focused===true||a.focused==='true'}; if(id==='account.email.input'||id==='account.code.input'){ @@ -138,7 +143,9 @@ export function nativeAuthUiFlags(hierarchy,{email='',code=''}={}){ } fields[id]=flags; } - for(const child of node?.children??[])visit(child); + for(const child of node?.children??[])visit(child,visible,insideSignOut); } - visit(hierarchy);return fields; + visit(hierarchy); + const confirmation=observed.find(([title])=>title)??[false,false,false]; + return {...fields,signOutConfirmation:{titleVisible:confirmation[0],cancelVisible:confirmation[1],actionVisible:confirmation[2]}}; } diff --git a/scripts/native-account-evidence.test.mjs b/scripts/native-account-evidence.test.mjs index 40e7f82c..375d1a7f 100644 --- a/scripts/native-account-evidence.test.mjs +++ b/scripts/native-account-evidence.test.mjs @@ -121,3 +121,23 @@ test('auth failure diagnostics retain readiness categories only, never secrets o assert.equal(flags.valueCategory,category);assert.equal(flags.exactExpectedMatch,false); } }); + +test('sign-out diagnostics expose only fixed control flags and public confirmation booleans',()=>{ + const email='private-synthetic@example.invalid',code='739201',secret='private response body '+email+' '+code; + const button={attributes:{'resource-id':'account.signOut',enabled:'false',focused:'true',text:'Sign out',value:secret},children:[{attributes:{text:'Sign out'}}]}; + const absent={titleVisible:false,cancelVisible:false,actionVisible:false}; + const initial=nativeAuthUiFlags({children:[button,{attributes:{'resource-id':'unknown-'+email,text:secret}}]},{email,code}); + assert.deepEqual(initial['account.signOut'],{present:true,enabled:false,focused:true}); + assert.deepEqual(initial.signOutConfirmation,absent); + for(const [title,cancel,action] of [['Sign out on this device?','Cancel','Sign out'],['¿Cerrar sesión en este dispositivo?','Cancelar','Cerrar sesión'],['在这台设备上退出登录?','取消','退出登录']]){ + const dialog={children:[{attributes:{text:title}},{attributes:{accessibilityText:cancel}},{attributes:{text:action}},{attributes:{text:secret,value:code}}]}; + const result=nativeAuthUiFlags({children:[button,dialog]},{email,code}); + assert.deepEqual(result.signOutConfirmation,{titleVisible:true,cancelVisible:true,actionVisible:true}); + for(const value of [email,code,secret,'unknown-',title,cancel,action])assert.ok(!JSON.stringify(result).includes(value)); + assert.deepEqual(nativeAuthUiFlags({...dialog,attributes:{visible:'false'}}).signOutConfirmation,absent); + assert.deepEqual(nativeAuthUiFlags({...dialog,attributes:{displayed:false}}).signOutConfirmation,absent); + } + const titleOnly=nativeAuthUiFlags({children:[button,{attributes:{text:'Sign out on this device?'}}]}); + assert.deepEqual(titleOnly.signOutConfirmation,{titleVisible:true,cancelVisible:false,actionVisible:false}); + assert.deepEqual(nativeAuthUiFlags({attributes:{text:'Sign out on this device? '+secret}}).signOutConfirmation,absent); +}); From 8849c58eba24261fbd249cd6477b843aae5b8678 Mon Sep 17 00:00:00 2001 From: Patrick Chin Date: Sun, 15 Nov 2026 04:37:08 +0800 Subject: [PATCH 08/37] Bound Android root readback and owned emulator cleanup (#218) * Bound Android root readback and owned emulator cleanup * Recognize exact SDK headless QEMU exec --------- Co-authored-by: Patrick Chin <1392122+patrickchin@users.noreply.github.com> --- .github/workflows/android-signing-proof.yml | 29 ++--- scripts/verify-android-signing-upgrade.mjs | 124 +++++++++++++++++++- 2 files changed, 130 insertions(+), 23 deletions(-) diff --git a/.github/workflows/android-signing-proof.yml b/.github/workflows/android-signing-proof.yml index 6b3ce9b3..78fdd11b 100644 --- a/.github/workflows/android-signing-proof.yml +++ b/.github/workflows/android-signing-proof.yml @@ -113,15 +113,8 @@ jobs: printf 'no\n' | avdmanager create avd --name "Puptime_Signing_QA_$GITHUB_RUN_ID" --package 'system-images;android-36;google_apis;x86_64' "$ANDROID_HOME/emulator/emulator" -avd "Puptime_Signing_QA_$GITHUB_RUN_ID" -port 5588 -no-window -no-audio -no-snapshot -gpu swiftshader_indirect > "$PUPTIME_SIGNING_PRIVATE/emulator.log" 2>&1 & echo "$!" > "$PUPTIME_SIGNING_PRIVATE/emulator.pid" - timeout 180 adb -s emulator-5588 wait-for-device - for attempt in $(seq 1 90); do - if [[ "$(adb -s emulator-5588 shell getprop sys.boot_completed | tr -d '\r')" == 1 ]]; then break; fi - if [[ "$attempt" = 90 ]]; then exit 1; fi - sleep 2 - done - test "$(adb -s emulator-5588 shell getprop sys.boot_completed | tr -d '\r')" = 1 - adb -s emulator-5588 root - timeout 60 adb -s emulator-5588 wait-for-device + node scripts/verify-android-signing-upgrade.mjs --emulator-record + node scripts/verify-android-signing-upgrade.mjs --emulator-root adb -s emulator-5588 shell svc wifi disable adb -s emulator-5588 shell svc data disable adb -s emulator-5588 shell cmd connectivity airplane-mode enable @@ -141,6 +134,12 @@ jobs: find "$PUPTIME_SIGNING_PRIVATE" -type d -exec chmod 700 {} + find "$PUPTIME_SIGNING_PRIVATE" -type f -exec chmod 600 {} + fi + - name: Remove owned emulator and private QA material + if: always() + run: | + if [[ -f "$PUPTIME_SIGNING_PRIVATE/NOT-FOR-RELEASE" ]]; then + node scripts/verify-android-signing-upgrade.mjs --emulator-cleanup + fi - name: Preserve only allowlisted non-secret comparison receipt if: always() uses: actions/upload-artifact@v6 @@ -149,15 +148,3 @@ jobs: path: build/android-signing-safe/verification.json if-no-files-found: ignore retention-days: 7 - - name: Remove owned emulator and private QA material - if: always() - shell: bash - run: | - set -euo pipefail - if [[ -f "$PUPTIME_SIGNING_PRIVATE/NOT-FOR-RELEASE" ]]; then - test "$(cat "$PUPTIME_SIGNING_PRIVATE/NOT-FOR-RELEASE")" = "$PUPTIME_EXPECTED_SOURCE" - if adb -s emulator-5588 emu avd name 2>/dev/null | grep -Fxq "Puptime_Signing_QA_$GITHUB_RUN_ID"; then - adb -s emulator-5588 emu kill - fi - rm -rf -- "$PUPTIME_SIGNING_PRIVATE" - fi diff --git a/scripts/verify-android-signing-upgrade.mjs b/scripts/verify-android-signing-upgrade.mjs index 3fcfd71b..e6f602f0 100644 --- a/scripts/verify-android-signing-upgrade.mjs +++ b/scripts/verify-android-signing-upgrade.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import {createHash,randomBytes} from 'node:crypto'; import {execFileSync,spawnSync} from 'node:child_process'; -import {createReadStream,createWriteStream,existsSync,lstatSync,mkdirSync,openSync,closeSync,readFileSync,readdirSync,writeFileSync,chmodSync} from 'node:fs'; +import {createReadStream,createWriteStream,existsSync,lstatSync,mkdirSync,openSync,closeSync,readFileSync,readdirSync,writeFileSync,chmodSync,readlinkSync,realpathSync,rmSync} from 'node:fs'; import {join,resolve,isAbsolute} from 'node:path'; import {Readable,Transform} from 'node:stream'; import {pipeline} from 'node:stream/promises'; @@ -72,6 +72,7 @@ function widgetBinding(text){ assert.ok(id&&host&&provider);return {id,host,provider}; } function selfCheck(){ + emulatorSelfCheck(); assertWidgetSource(); validateDriver('d'.repeat(40),'d'.repeat(40));assert.throws(()=>validateDriver('d'.repeat(40),'e'.repeat(40)));assert.throws(()=>validateDriver('short','short')); const change=path=>`:100644 100644 ${'a'.repeat(40)} ${'b'.repeat(40)} M\0${path}\0`; @@ -93,7 +94,126 @@ function selfCheck(){ const dump='\nWidgets:\n [0] id=2\n host=HostId{user:0, app:10001, hostId:1024, pkg:launcher}\n provider=ProviderId{user:0, app:10002, cmp:ComponentInfo{'+app+'/'+app+'.widget.PuptimeQuickLog}}\n views=random\nHosts:\n';assert.equal(widgetBinding(dump).id,'2');assert.throws(()=>widgetBinding(dump.replace(app+'/','other/')));assert.throws(()=>widgetBinding(dump.replace(' host=',' invalid='))); console.log('PASS signing QA source self-checks; no network, keys, signing or device operations.'); } -const [mode]=process.argv.slice(2);if(mode==='--self-check'){selfCheck();process.exit(0);} +function emulatorSelfCheck(){ + const base={alive:true,start:'42',executable:true,arguments:true,home:true,uid:true}; + const stub=()=>{let now=0,alive=true;const calls=[],state={},io={name:'Puptime_Signing_QA_123',start:'42',inspect:()=>({...base,alive}),save:()=>{},now:()=>now,pause:()=>now+=1000,signal:signal=>{calls.push(signal);alive=false;},adb:args=>{calls.push(args.join(' '));return {status:0,stdout:args[0]==='root'?'restarting adbd as root\n':args[0]==='emu'?'Puptime_Signing_QA_123\nOK\n':args[1]==='id'?'0\n':'1\n',stderr:''};}};return {io,state,calls,exit:()=>alive=false};}; + const closed={status:1,stdout:'',stderr:'adb: unable to connect for root: closed\n'}; + for(const outcome of [{status:0,stdout:'restarting adbd as root\n',stderr:''},{status:0,stdout:'adbd is already running as root\n',stderr:''},closed]){ + const t=stub(),adb=t.io.adb;let reads=0;t.io.adb=args=>args[0]==='root'?(t.calls.push('root'),outcome):args[1]==='id'&&reads++===0?{status:1,stdout:'',stderr:'closed'}:adb(args);emulatorRoot(t.io,t.state);assert.equal(t.state.rootReadback,'verified');assert.equal(t.calls.filter(c=>c==='root').length,1); + } + for(const outcome of [{status:1,stdout:'',stderr:'permission denied\n'},{status:0,stdout:'adbd cannot run as root in production builds\n',stderr:''},{status:null,stdout:'',stderr:''},{...closed,stderr:closed.stderr+'extra\n'}]){ + const t=stub(),adb=t.io.adb;t.io.adb=args=>args[0]==='root'?(t.calls.push('root'),outcome):adb(args);assert.throws(()=>emulatorRoot(t.io,t.state));assert.equal(t.state.rootRequest,'refused');assert.equal(t.calls.filter(c=>c==='root').length,1); + } + for(const fault of ['nonroot','wrong-avd','dead','pid-reuse','wrong-executable']){ + const t=stub(),adb=t.io.adb;let requested=false;t.io.adb=args=>{if(args[0]==='root'){t.calls.push('root');requested=true;return closed;}if(requested&&fault==='nonroot'&&args[1]==='id')return {status:0,stdout:'2000\n',stderr:''};if(requested&&fault==='wrong-avd'&&args[0]==='emu')return {status:0,stdout:'Foreign\nOK\n',stderr:''};return adb(args);};t.io.inspect=()=>({...base,...(requested?fault==='dead'?{alive:false}:fault==='pid-reuse'?{start:'99'}:fault==='wrong-executable'?{executable:false}:{}:{})});assert.throws(()=>emulatorRoot(t.io,t.state));assert.notEqual(t.state.rootReadback,'verified');assert.equal(t.calls.filter(c=>c==='root').length,1); + } + for(const field of ['start','executable','arguments','home','uid']){const t=stub();t.io.inspect=()=>({...base,[field]:field==='start'?'99':false});assert.equal(stopEmulator(t.io,t.state),false);assert.equal(t.state.cleanup,'ownership-refused');assert.deepEqual(t.calls,[]);} + {const t=stub();t.exit();assert.equal(stopEmulator(t.io,t.state),true);assert.equal(t.state.cleanup,'already-exited');assert.deepEqual(t.calls,[]);} + {const t=stub(),adb=t.io.adb;t.io.adb=args=>{const value=adb(args);if(args[1]==='kill')t.exit();return value;};assert.equal(stopEmulator(t.io,t.state),true);assert.equal(t.state.cleanup,'console-exit');assert.ok(!t.calls.includes('SIGTERM'));} + for(const required of ['SIGTERM','SIGKILL']){const t=stub();t.io.adb=()=>closed;t.io.signal=signal=>{t.calls.push(signal);if(signal===required)t.exit();};assert.equal(stopEmulator(t.io,t.state),true);assert.equal(t.state.cleanup,required==='SIGTERM'?'term-exit':'kill-exit');assert.deepEqual(t.calls,required==='SIGTERM'?['SIGTERM']:['SIGTERM','SIGKILL']);} + {const t=stub();t.io.adb=()=>closed;t.io.signal=signal=>t.calls.push(signal);assert.equal(stopEmulator(t.io,t.state),false);assert.equal(t.state.cleanup,'still-alive');assert.deepEqual(t.calls,['SIGTERM','SIGKILL']);} + {const t=stub();let replaced=false;t.io.adb=()=>closed;t.io.signal=signal=>{t.calls.push(signal);replaced=true;};t.io.inspect=()=>({...base,start:replaced?'99':'42'});assert.equal(stopEmulator(t.io,t.state),false);assert.equal(t.state.cleanup,'ownership-refused');assert.deepEqual(t.calls,['SIGTERM']);} + + const stat='123 (qemu with spaces) S '+Array(18).fill('0').join(' ')+' 42 0',expected={executables:['/sdk/emulator/emulator','/sdk/emulator/qemu/linux-x86_64/qemu-system-x86_64','/sdk/emulator/qemu/linux-x86_64/qemu-system-x86_64-headless'],arguments:['-avd','Puptime_Signing_QA_123','-port','5588','-no-window','-no-audio','-no-snapshot','-gpu','swiftshader_indirect'],home:'/private/qa/avd',uid:'1001'}; + const files={stat,afterStat:stat,exe:expected.executables[0],cmdline:[expected.executables[0],...expected.arguments].join('\0')+'\0',environ:'UNRELATED=private-not-retained\0ANDROID_AVD_HOME='+expected.home+'\0',status:'Name:\tqemu\nUid:\t1001\t1001\t1001\t1001\n'}; + for(const exe of expected.executables)assert.ok(ownedProcess(processIdentity({...files,exe,cmdline:[exe,...expected.arguments].join('\0')+'\0'},expected),'42'),'Accept same-PID launcher→QEMU exec with unchanged tail args.'); + for(const patch of [{exe:'/foreign/qemu'},{afterStat:stat.replace(') S ',') Z ')},{cmdline:files.cmdline.replace('/sdk/emulator/emulator','/foreign/argv0')},{cmdline:files.cmdline.replace('QA_123','QA_999')},{cmdline:files.cmdline+'-extra\0'},{environ:'ANDROID_AVD_HOME=/foreign\0'},{environ:'OTHER_ANDROID_AVD_HOME='+expected.home+'\0'},{status:files.status.replaceAll('1001','1002')},{status:files.status.replace('1001\t1001','1001\t0')},{environ:files.environ+'ANDROID_AVD_HOME=/foreign\0'},{stat:stat.replace(' 42 ',' 99 '),afterStat:stat.replace(' 42 ',' 99 ')}])assert.ok(!ownedProcess(processIdentity({...files,...patch},expected),'42')); + assert.throws(()=>processIdentity({...files,afterStat:stat.replace(' 42 ',' 99 ')},expected)); + {const t=stub();let reads=0;t.io.inspect=()=>({...base,executable:reads++>1});recordOwnedEmulator(t.io,t.state);assert.equal(t.state.liveness,'owned');assert.ok(t.io.now()<5000);} + for(const fault of [{executable:false},{start:'99'},{alive:false}]){const t=stub();t.io.inspect=()=>({...base,...fault});assert.throws(()=>recordOwnedEmulator(t.io,t.state));assert.notEqual(t.state.liveness,'owned');assert.deepEqual(t.calls,[]);assert.ok(t.io.now()<=5000);} + assert.deepEqual(safeHostState({liveness:'owned',argv:'PRIVATE',path:'PRIVATE',pid:123}),{liveness:'owned'});assert.throws(()=>safeHostState({liveness:'PRIVATE'})); + assert.deepEqual(processStart('123 (qemu with spaces) S '+Array(18).fill('0').join(' ')+' 42 0'),{start:'42',alive:true});assert.equal(processStart('123 (qemu) Z '+Array(18).fill('0').join(' ')+' 42 0').alive,false);assert.throws(()=>processStart('invalid')); + console.log('PASS bounded one-request root/readback, process ownership/PID reuse, and console/TERM/KILL cleanup stubs. No processes, adb or emulator invoked.'); +} + +// Host-only setup/cleanup. Root is requested once; a closed transport is not proof +// of success. AOSP documents adbd's restart: docs/dev/root.md in packages/modules/adb. +function processStart(stat){const fields=stat.slice(stat.lastIndexOf(')')+2).trim().split(/\s+/);assert.match(fields[19]??'',/^\d+$/);return {start:fields[19],alive:!['Z','X'].includes(fields[0])};} +function safeHostState(value){ + const allowed={liveness:['not-started','owned','identity-mismatch','exited'],boot:['not-started','pending','complete'],rootRequest:['not-issued','acknowledged','closed-transport','refused'],rootReadback:['not-attempted','pending','verified','timeout','identity-mismatch'],cleanup:['not-attempted','already-exited','console-exit','term-exit','kill-exit','ownership-refused','still-alive'],setup:['failed']},safe={}; + for(const [key,values] of Object.entries(allowed))if(key in value){assert.ok(values.includes(value[key]));safe[key]=value[key];} + if('privateMaterialRemoved' in value){assert.equal(typeof value.privateMaterialRemoved,'boolean');safe.privateMaterialRemoved=value.privateMaterialRemoved;}return safe; +} +function ownedProcess(value,start){return !!value?.alive&&value.start===start&&value.executable&&value.arguments&&value.home&&value.uid;} +function processIdentity(files,expected){ + const stat=processStart(files.stat);if(!stat.alive)return stat; + const after=processStart(files.afterStat);assert.equal(after.start,stat.start,'Process changed during /proc read.'); + const argv=files.cmdline.split('\0').filter(Boolean),uids=files.status.match(/^Uid:\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m)?.slice(1); + return {...stat,alive:after.alive,executable:expected.executables.includes(files.exe),arguments:expected.executables.includes(argv[0])&&JSON.stringify(argv.slice(1))===JSON.stringify(expected.arguments),home:JSON.stringify(files.environ.split('\0').filter(value=>value.startsWith('ANDROID_AVD_HOME=')))===JSON.stringify(['ANDROID_AVD_HOME='+expected.home]),uid:!!uids&&uids.every(value=>value===expected.uid)}; +} +function recordOwnedEmulator(io,state){ + const until=io.now()+5000;do{const value=io.inspect();if(ownedProcess(value,io.start)){state.liveness='owned';io.save(state);return;}if(!value?.alive||value.start!==io.start)break;io.pause();}while(io.now(){const value=io.inspect();state.liveness=value?.alive?(ownedProcess(value,io.start)?'owned':'identity-mismatch'):'exited';io.save(state);assert.equal(state.liveness,'owned','Recorded emulator process is not live and owned.');}; + const poll=(seconds,read)=>{const until=io.now()+seconds*1000;do{live();if(read())return true;io.pause();}while(io.now(){const result=io.adb(['emu','avd','name']);if(result.status!==0)return false;if(result.stdout.replaceAll('\r','').trim()!==io.name+'\nOK'){state.rootReadback='identity-mismatch';io.save(state);throw new Error('Unexpected emulator console identity.');}return true;}; + state.boot='pending';io.save(state); + assert.ok(poll(180,()=>{if(!name())return false;const boot=io.adb(['shell','getprop','sys.boot_completed']);return boot.status===0&&boot.stdout.trim()==='1';}),'Owned emulator boot readback timed out.');state.boot='complete'; + const result=io.adb(['root'],20000),text=result.stdout+result.stderr; + state.rootRequest=result.status===0&&['','adbd is already running as root\n','restarting adbd as root\n'].includes(text.replaceAll('\r',''))?'acknowledged':result.status===1&&result.stdout===''&&result.stderr.replaceAll('\r','')==='adb: unable to connect for root: closed\n'?'closed-transport':'refused';io.save(state); + assert.notEqual(state.rootRequest,'refused','Root request failed outside the observed closed-transport case.'); + state.rootReadback='pending';io.save(state); + const ready=poll(60,()=>{ + if(!name())return false; + const boot=io.adb(['shell','getprop','sys.boot_completed']),uid=io.adb(['shell','id','-u']); + return boot.status===0&&boot.stdout.trim()==='1'&&uid.status===0&&uid.stdout.trim()==='0'; + });state.rootReadback=ready?'verified':'timeout';io.save(state);assert.ok(ready,'Root UID/boot/owned-AVD readback timed out; no second root request.'); +} +function stopEmulator(io,state){ + const inspect=()=>{const value=io.inspect();if(!value?.alive)return 'exited';return ownedProcess(value,io.start)?'owned':'identity-mismatch';}; + const wait=seconds=>{const until=io.now()+seconds*1000;do{const value=inspect();if(value!=='owned')return value;io.pause();}while(io.now(){state.cleanup=value;io.save(state);return !['ownership-refused','still-alive'].includes(value);}; + let current=inspect();if(current==='exited')return finish('already-exited');if(current!=='owned')return finish('ownership-refused'); + const name=io.adb(['emu','avd','name']); + if(name.status===0&&name.stdout.replaceAll('\r','').trim()===io.name+'\nOK'){ + io.adb(['emu','kill']);current=wait(10);if(current==='exited')return finish('console-exit');if(current!=='owned')return finish('ownership-refused'); + } + for(const [signal,seconds,label] of [['SIGTERM',10,'term-exit'],['SIGKILL',5,'kill-exit']]){ + current=inspect();if(current==='exited')return finish('already-exited');if(current!=='owned')return finish('ownership-refused'); + io.signal(signal);current=wait(seconds);if(current==='exited')return finish(label);if(current!=='owned')return finish('ownership-refused'); + } + return finish('still-alive'); +} +function emulatorHost(mode){ + assert.equal(process.env.GITHUB_ACTIONS,'true');assert.equal(process.env.RUNNER_OS,'Linux');validateDriver(process.env.PUPTIME_EXPECTED_DRIVER,process.env.GITHUB_SHA);process.umask(0o077); + const {PUPTIME_SIGNING_PRIVATE:privatePath,RUNNER_TEMP,GITHUB_RUN_ID:run,GITHUB_RUN_ATTEMPT:attempt,ANDROID_HOME:sdk}=process.env; + assert.match(run??'',/^[1-9]\d*$/);assert.match(attempt??'',/^[1-9]\d*$/);assert.ok(RUNNER_TEMP&&privatePath&&sdk); + const folder=resolve(privatePath);assert.equal(folder,join(resolve(RUNNER_TEMP),'puptime-signing-'+run+'-'+attempt));assert.ok(!lstatSync(folder).isSymbolicLink());assert.equal(lstatSync(folder).mode&0o777,0o700);assert.equal(readFileSync(join(folder,'NOT-FOR-RELEASE'),'utf8'),process.env.PUPTIME_EXPECTED_SOURCE); + try{ + const name='Puptime_Signing_QA_'+run,ownerFile=join(folder,'emulator-owner.json'),stateFile=join(folder,'emulator-host-state.json'); + const state=existsSync(stateFile)?JSON.parse(readFileSync(stateFile)): {liveness:'not-started',boot:'not-started',rootRequest:'not-issued',rootReadback:'not-attempted',cleanup:'not-attempted'}; + const save=value=>writeFileSync(stateFile,JSON.stringify(safeHostState(value)),{mode:0o600}); + const pidFile=join(folder,'emulator.pid');let owner=existsSync(ownerFile)?JSON.parse(readFileSync(ownerFile)):null; + const pid=owner?.pid??(existsSync(pidFile)?Number(readFileSync(pidFile,'utf8').trim()):null);if(pid!==null)assert.ok(Number.isSafeInteger(pid)&&pid>1); + const executables=[join(sdk,'emulator/emulator'),join(sdk,'emulator/qemu/linux-x86_64/qemu-system-x86_64'),join(sdk,'emulator/qemu/linux-x86_64/qemu-system-x86_64-headless')].filter(existsSync).map(path=>realpathSync(path)); + const inspect=()=>{ + if(pid===null)return null; + try{ + const proc='/proc/'+pid,stat=processStart(readFileSync(join(proc,'stat'),'utf8'));if(!stat.alive)return stat; + return processIdentity({stat:readFileSync(join(proc,'stat'),'utf8'),exe:readlinkSync(join(proc,'exe')),cmdline:readFileSync(join(proc,'cmdline'),'utf8'),environ:readFileSync(join(proc,'environ'),'utf8'),status:readFileSync(join(proc,'status'),'utf8'),afterStat:readFileSync(join(proc,'stat'),'utf8')},{executables,arguments:['-avd',name,'-port','5588','-no-window','-no-audio','-no-snapshot','-gpu','swiftshader_indirect'],home:join(folder,'avd'),uid:String(process.getuid())}); + }catch(error){if(error.code==='ENOENT'||error.code==='ESRCH')return null;throw error;} + }; + const io={name,start:owner?.start,inspect,save,now:Date.now,pause:()=>Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,1000),signal:signal=>{try{process.kill(pid,signal);}catch(error){if(error.code!=='ESRCH')throw error;}},adb:(args,timeout=3000)=>{const value=spawnSync(join(sdk,'platform-tools/adb'),['-s','emulator-5588',...args],{encoding:'utf8',timeout,maxBuffer:65536,stdio:['ignore','pipe','pipe']});return {status:value.status,stdout:value.stdout??'',stderr:value.stderr??''};}}; + if(mode==='--emulator-record'){ + assert.equal(owner,null);assert.ok(pid);const value=processStart(readFileSync('/proc/'+pid+'/stat','utf8'));assert.ok(value.alive);owner={pid,start:value.start};writeFileSync(ownerFile,JSON.stringify(owner),{flag:'wx',mode:0o600});io.start=value.start;recordOwnedEmulator(io,state); + }else if(mode==='--emulator-root'){ + assert.ok(owner);try{emulatorRoot(io,state);}catch(error){state.setup='failed';save(state);throw error;} + }else{ + let stopped=false; + try{stopped=stopEmulator(io,state);}finally{ + if(stopped){rmSync(folder,{recursive:true});state.privateMaterialRemoved=true;}else state.privateMaterialRemoved=false; + const receiptPath=resolve('build/android-signing-safe/verification.json'); + if(existsSync(receiptPath)){const receipt=JSON.parse(readFileSync(receiptPath));receipt.emulatorHost=safeHostState(state);writeFileSync(receiptPath,JSON.stringify(receipt,null,2),{mode:0o600});} + } + assert.ok(stopped,'Owned emulator exit could not be confirmed; cleanup is incomplete.'); + } + console.log('PASS host-only '+mode.slice(2)+'; no keys or app operations.'); + }catch(error){if(existsSync(folder))writeFileSync(join(folder,'emulator-host-failure.txt'),String(error.stack),{mode:0o600});throw error;} +} + +const [mode]=process.argv.slice(2);if(['--emulator-record','--emulator-root','--emulator-cleanup'].includes(mode)){try{emulatorHost(mode);process.exit(0);}catch{console.error('FAIL host-only '+mode.slice(2)+'; raw process/error evidence remains private.');process.exit(1);}}if(mode==='--self-check'){selfCheck();process.exit(0);} assert.ok(['--preflight','--exercise'].includes(mode));assert.equal(process.env.GITHUB_ACTIONS,'true');assert.equal(process.env.RUNNER_OS,'Linux');process.umask(0o077); const root=resolve(process.env.PUPTIME_SIGNING_PRIVATE??'');assert.ok(process.env.RUNNER_TEMP&&root.startsWith(resolve(process.env.RUNNER_TEMP)+'/puptime-signing-')); const source=process.env.PUPTIME_EXPECTED_SOURCE,driverSource=process.env.PUPTIME_EXPECTED_DRIVER;assert.match(source??'',/^[a-f0-9]{40}$/);validateDriver(driverSource,process.env.GITHUB_SHA);assertWidgetSource(); From 0597f4d66ab5b379cb03dad074495dd8052fc3f5 Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 04:46:01 +0800 Subject: [PATCH 09/37] Close notice capture provenance and archive gaps --- README.md | 2 +- docs/app-notices.md | 101 +++++++++++++++++++---- scripts/app-notice-provenance.json | 10 +-- scripts/app-notices.mjs | 113 ++++++++++++++++++++------ scripts/app-notices.test.mjs | 86 ++++++++++++++++---- scripts/capture-app-notices.mjs | 59 +++++++++----- scripts/font-icon-provenance.json | 8 ++ scripts/native-notice-inputs.mjs | 46 +++++------ scripts/native-notice-inputs.test.mjs | 71 +++++++++++++++- scripts/notice-archive.py | 36 ++++++-- scripts/notices-runtime.init.gradle | 2 +- src/font-icon-notices.test.ts | 3 +- 12 files changed, 417 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index abff49a2..9f5c28ec 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ The screenshots, features, and Android installation instructions below describe - Choose two to four Home Screen widget actions and optional confirmation notifications. - Use the default style in Light or Dark mode, or follow the device setting. Unsupported saved preferences fail visibly while preserving their stored values. - Manage appearance, language, widget actions, and activity data in Settings. Manage reminders and device delivery in Notifications. -- Browse illustrated topic cards in **Learn**, with a short getting-started guide and 12 detailed puppy-care articles. Each article opens with an at-a-glance overview and links to its sections and sources, plus further reading and selected YouTube demonstrations. The same guides are on the website under **Puppy care**. Articles are currently in English; in-app navigation supports all app languages. Text and bundled English ElevenLabs narration work offline in the app; external resources need a browser and internet connection. Each guide has play/pause, 15-second skips, and playback speed controls. +- Browse illustrated topic cards in **Learn**, with a short getting-started guide and 12 detailed puppy-care articles. Each article opens with an at-a-glance overview and links to its sections and sources, plus further reading and selected YouTube demonstrations. The same guides are on the website under **Puppy care**. Articles are currently in English; in-app navigation supports all app languages. Written English guides work offline in the app; external links need an internet connection. - Keep activity data on the device without an account, subscription, server or ads. Optional anonymous usage analytics are off by default and controlled in Settings. The starter routine is only an editable example, not veterinary guidance. Change it to fit your puppy and your veterinarian’s advice. diff --git a/docs/app-notices.md b/docs/app-notices.md index 4223fb30..9b99e250 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -64,7 +64,8 @@ paths, licence source bodies, raw maps, environment values or application data. `seal` first retains that discovery report, then fails unless actual Metro and native inputs are complete, all vendors/text are reviewed, font pins match, the -payload/ledger/relevant source files equal Git HEAD, and the view imports the same +payload/ledger/package manifest/lock/app config/relevant source files equal Git +HEAD in the canonical checkout root, and the view imports the same resource. A passing `seal.json` records that exact source/lock/native-input graph. Missing input metadata or a font-only placeholder cannot pass. A failed seal removes previous seal/final receipts. There is no generation or fallback mode. @@ -72,9 +73,12 @@ removes previous seal/final receipts. There is no generation or fallback mode. `verify` revalidates the same source, native inputs and committed payload, then requires the packaged raw JSON to match byte for byte. It compares the packaged bundle hash with the mapped build output and requires the final composed map's -source contents to equal captured source bytes. Code resolved from another +source contents to equal captured source bytes. Observed component IDs come from +those actual map entries and mapped archive-native entries, not the expected +coverage list. Code resolved from another checkout, stale embedded source text, new modules and unmapped native files fail. -Only the current worktree and its explicitly identified installed `node_modules` +Missing asset-only packages and extra modules also fail. Only the current +worktree and its explicitly identified installed `node_modules` root are accepted, with realpaths normalized. Raw source maps stay private. Native stripping may change a `.so` hash. Final Android ABI/library names are @@ -82,6 +86,22 @@ bound to the verified source AAR's actual libraries; final hashes are recorded separately. Additional generated/NDK/Swift/framework inputs need explicit reviewed component mappings in the ledger. Nothing is silently called app-owned. All nine previously shipped Android LICENSE/NOTICE resources must retain their bytes. +The archive reader inspects Mach-O magic for every regular entry, including +extensionless main/appex executables. The exact JSON resource has its own 16 MiB +limit and is excluded from generic 1 MiB notice-file classification. +Universal headers are distinguished from Java class files sharing `CAFEBABE` +using bounded architecture-table offsets/sizes. Format references are Apple's +[fat header](https://github.com/apple-oss-distributions/cctools/blob/main/include/mach-o/fat.h) +and [JVMS 4.1](https://docs.oracle.com/javase/specs/jvms/se25/html/jvms-4.html#jvms-4.1). + +Java/static-native ownership cannot be independently recovered from DEX/static +linked bytes here. Actual resolved Pod/Gradle inputs are re-inspected after +packaging; IDs supported only by those inputs are separately listed under +`resolvedBuildOnly` with their basis. Locked generated Metro providers are also +explicit. Receipts record `independentFinalComponentInventory: false` and do not +claim a newly regenerated Gradle resolution. Unknown/missing component IDs still +fail; no generic omission waiver or independent native final-byte identity is +implied by the complete *notice coverage* check. For an IPA, use `seal ios` after pod setup, set `SOURCEMAP_FILE` to a private absolute path for the normal Xcode build (the installed Expo/RN script composes @@ -91,35 +111,48 @@ Keep normal version, signing, audio, provider, device and store gates separate. ## Reviewed ledger contract -`scripts/app-notice-provenance.json` records exact component IDs such as -`npm:react-native@0.86.3`, `cocoapods:Expo@57.0.24`, or Maven coordinates, actual -notice source/hash sets, `reviewedVendors`, explicit `platforms`, a pinned -`upstream` reference and the combined section's `sectionSHA256`. Deterministic -section IDs are `library-` plus the first 24 SHA-256 hex characters of the exact -component ID. Body text is the actual notice files, sorted by logical source and -joined with two newlines. Keep separate NOTICE/multiple-vendor texts. +`scripts/app-notice-provenance.json` has a `components` array indexed by each +record's explicit `platform` (`ios` or `android`) and unchanged actual `component` +ID, such as `npm:react-native@0.86.3`. Duplicate platform/component pairs fail. +Each record pins actual notice source/hash sets, `reviewedVendors`, native source +`nativeInputs` fingerprints, `upstream` and `sectionSHA256`. Section IDs are +`library-` plus the first 24 SHA-256 hex characters of `platform + ':' + component`. +Each is explicitly `iosOnly` or `androidOnly` in the ONE committed JSON. This lets +one npm ID have extra Android-native notices without weakening iOS comparison. +The union must contain every reviewed platform section, not only the current +target. Bodies are actual files sorted by logical source, joined with two newlines. +Font visibility is independently pinned in `font-icon-provenance.json` too. When an AAR/JAR omits full text, an explicitly reviewed `additionalNoticeFiles` entry may point at an actual pinned upstream copy under `assets/notices/sources/` with `file`, `sha256`, `upstream`. It supplements discovered notices; it cannot replace a missing NOTICE with a package label or an assumed parent licence. -Generated Metro sources require an explicit `generatedSources` provider/hash -mapping. Final native files not identified from resolved AARs require an exact +Generated Metro sources permit only the known `__prelude__`, with its exact +installed Metro generator path/hash. Final native files not identified from resolved AARs require an exact platform/path `packagedNative` mapping with covered `components` and a pinned `review` reference. These mappings are intentionally empty until discovery. Metro inputs are selected files, not every declared or installed package. Nested packages have their own licence boundary. CocoaPods inputs include resolved pod versions, generated acknowledgements, actual source notice files and vendored -framework/library names. Gradle inputs are the actual release runtime resolution -and artifact hashes, including autolinked native projects. Missing source/text, +framework/library names. Raw Pod lock/plist/notice bytes must be valid UTF-8; +invalid bytes are rejected before decoding, never repaired. Gradle inputs are the +actual release runtime resolution and artifact hashes. A shared bounded scanner +walks Pods and autolinked Gradle native subtrees, fingerprints source files, and +finds source-only `vendor`/`third_party` children as well as binary vendors. +Native roots are restricted to installed packages, Pods and identified local +modules; arbitrary project data is not traversed. +Only the actual Gradle build-directory output and Git metadata are excluded. +Vendor text must be present within its boundary, or explicitly mapped to a pinned +actual supplemental notice using `vendorNoticeSources`; approving a vendor name +alone cannot borrow the root licence. Missing source/text, unreviewed vendor boundaries and changed digests stay unresolved. A POM/SPDX label alone is insufficient. This is scoped notice evidence, not a complete transitive SBOM or an independent legal certification. ## Proposed workflow wiring — not active in this preparation -No build workflow is changed here. Review this exact behavior before wiring: +No native build/upload workflow is changed here. Review this exact behavior before wiring: 1. Normal simulator CI: after `pod install`, run **collect only**, with an explicit INCOMPLETE/non-release-qualified step name and job-summary notice. @@ -132,6 +165,9 @@ No build workflow is changed here. Review this exact behavior before wiring: metadata stops before compile, preserving sanitized discovery for review. Commit the reviewed union/ledger first; do not plan a throwaway native build or overwrite assets during the build. + The current producer edits `app.json` for its version override; that dirty + config is deliberately rejected. Its frozen-source/version policy must be + reviewed when wiring this gate; there is no ignore-dirty switch. 3. After the ordinary build, run strict **verify for both APK and AAB before** artifact uploads or the existing publisher. Only exact READY receipts and package hashes qualify; no `continue-on-error` or placeholder success. @@ -143,6 +179,41 @@ No build workflow is changed here. Review this exact behavior before wiring: new host/resource rendering, complete native input capture and final-artifact execution are **pending**, not supplied by these source tests. +## Separate unreviewed input artifacts — upload not enabled + +Discovery retains `build/notices//private/unreviewed-inputs.json` in a +0700 directory/0600 file. It contains original component notice inputs, explicitly +INCOMPLETE/discoveryOnly, not view sections or a seal. Never upload `private/`. + +A separate `review/unreviewed-oss-notice-inputs.json` includes bodies only when +the exact component/source/SHA is explicitly pinned as public in +`knownLicenseSources`, with explicit ecosystem and version too. Public npm, +CocoaPods and Maven originals use the same manual pin contract; metadata alone +never implies public origin. The artifact allowlist contains only component/version/ecosystem, +normalized logical notice source, exact SHA and original UTF-8 body. Uncertain +or private-origin records expose only hashes and finite unresolved reasons. No +body is redacted or rewritten. Limits are 256 public bodies/4 MiB; over-budget +records remain hash-only. All captured input text is capped at 16 MiB. + +After review, an optional upload would use exactly the following path (not a +directory glob), separately from any build qualification artifact: + +```yaml +- uses: actions/upload-artifact@v6 + if: always() + with: + name: unreviewed-oss-notice-inputs-ios + path: build/notices/ios/review/unreviewed-oss-notice-inputs.json + if-no-files-found: warn + retention-days: 3 +``` + +Its content is always `INCOMPLETE`, `complete:false`, `discoveryOnly:true`. +The analogous Android path substitutes `android` for `ios`. No raw maps/cache, +source archives, owner paths, credentials or customer records are included. This +artifact supports technical review after the hosted runner disappears; it is +not a payload generator, legal certificate or release gate success. + Current npm/lock/font inputs and prior packaged Android notices are available. Current generated CocoaPods acknowledgements, complete resolved Gradle artifacts, final candidate maps and native-runtime metadata were not preserved in the prior diff --git a/scripts/app-notice-provenance.json b/scripts/app-notice-provenance.json index f8ea7f4a..75ae5637 100644 --- a/scripts/app-notice-provenance.json +++ b/scripts/app-notice-provenance.json @@ -1,14 +1,14 @@ { "schema": 1, "scope": "INCOMPLETE preparation only. Collect actual Metro/native inputs and commit reviewed platform-union text and mappings before sealing. Empty mappings deliberately fail strict verification; discovery never writes a release payload.", - "components": {}, + "components": [], "generatedSources": {}, "packagedNative": {"ios": {}, "android": {}}, "knownLicenseSources": [ - {"component":"react-native@0.86.3","source":"node_modules/react-native/LICENSE","sha256":"da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93","upstream":"https://github.com/react/react-native/blob/v0.86.3/LICENSE"}, - {"component":"expo@57.0.24","source":"node_modules/expo/LICENSE","sha256":"fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4","upstream":"https://github.com/expo/expo/blob/sdk-57/LICENSE"}, - {"component":"posthog-react-native@4.84.1","source":"node_modules/posthog-react-native/LICENSE","sha256":"d3fb92ac01f0bef4078f89aac9f20f76da3383ad45becf6204abdf5495f43f8f","note":"Actual LICENSE includes Apache/Mixpanel text despite MIT package metadata; preserve actual files and review vendored sources."}, - {"component":"better-auth@1.7.12","source":"node_modules/better-auth/LICENSE.md","sha256":"7a07f7d9085cb3157e4377d944988c9712933141b0f7201955ceccfac92abde4"} + {"component":"npm:react-native@0.86.3","ecosystem":"npm","version":"0.86.3","public":true,"source":"node_modules/react-native/LICENSE","sha256":"da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93","upstream":"https://github.com/react/react-native/blob/v0.86.3/LICENSE"}, + {"component":"npm:expo@57.0.24","ecosystem":"npm","version":"57.0.24","public":true,"source":"node_modules/expo/LICENSE","sha256":"fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4","upstream":"https://github.com/expo/expo/blob/sdk-57/LICENSE"}, + {"component":"npm:posthog-react-native@4.84.1","ecosystem":"npm","version":"4.84.1","source":"node_modules/posthog-react-native/LICENSE","sha256":"d3fb92ac01f0bef4078f89aac9f20f76da3383ad45becf6204abdf5495f43f8f","note":"Actual LICENSE includes Apache/Mixpanel text despite MIT package metadata; preserve actual files and review vendored sources."}, + {"component":"npm:better-auth@1.7.12","ecosystem":"npm","version":"1.7.12","source":"node_modules/better-auth/LICENSE.md","sha256":"7a07f7d9085cb3157e4377d944988c9712933141b0f7201955ceccfac92abde4"} ], "retainedAndroidResources": { "META-INF/androidx/annotation/annotation/LICENSE.txt":"809fa1ed21450f59827d1e9aec720bbc4b687434fa22283c6cb5dd82a47ab9c0", diff --git a/scripts/app-notices.mjs b/scripts/app-notices.mjs index 43e96195..6ec2d8d5 100644 --- a/scripts/app-notices.mjs +++ b/scripts/app-notices.mjs @@ -1,11 +1,13 @@ // Build-time notice evidence. Importing never starts Metro, a device or a build. import assert from 'node:assert/strict'; import {createHash} from 'node:crypto'; -import {existsSync,readFileSync,readdirSync,realpathSync,statSync} from 'node:fs'; +import {closeSync,existsSync,openSync,readFileSync,readSync,readdirSync,realpathSync,statSync} from 'node:fs'; import {dirname,isAbsolute,join,relative,resolve,sep} from 'node:path'; import {fileURLToPath} from 'node:url'; +import {isDeepStrictEqual} from 'node:util'; export const digest=bytes=>createHash('sha256').update(bytes).digest('hex'); +function fileDigest(path){const hash=createHash('sha256'),file=openSync(path,'r'),buffer=Buffer.alloc(1024*1024);try{let size;while((size=readSync(file,buffer,0,buffer.length,null)))hash.update(buffer.subarray(0,size));return hash.digest('hex');}finally{closeSync(file);}} const inside=(root,path)=>path===root||path.startsWith(root+sep); const noticeName=/^(licen[cs]es?|notices?|copying|copyright|third[._-]?party[._-]?(?:licen[cs]es?|notices?))([._-].*)?$/i; const vendorName=/^(vendor|vendored|third[-_]?party)$/i; @@ -34,6 +36,33 @@ function nearbyNotices(boundary,start,prefix){ return [...result.values()]; } +// Shared native-source boundary walk. Generated Gradle output is excluded only +// through the actual project build-directory metadata, never a vendor wildcard. +export function nativeSourceNotices(root,supplied,prefix,{excluded=[]}={}){ + const boundary=realpathSync(supplied),project=realpathSync(root),modules=existsSync(join(root,'node_modules'))?realpathSync(join(root,'node_modules')):null; + assert.ok((modules&&inside(modules,boundary))||boundary.startsWith(join(project,'ios/Pods')+sep)||boundary.startsWith(join(project,'modules')+sep),'Native source must be an identified package/Pod/local module, not arbitrary project data.'); + const canonicalFuture=path=>{path=resolve(path);let ancestor=path;while(!existsSync(ancestor))ancestor=dirname(ancestor);return resolve(realpathSync(ancestor),relative(ancestor,path));}; + const omitted=new Set(excluded.map(canonicalFuture)),files=[],notices=[],vendors=new Set(),visited=new Set();let count=0,total=0; + function walk(folder,logical=''){ + const canonical=realpathSync(folder);assert.ok(inside(boundary,canonical),'Native source link escapes its component.'); + if(visited.has(canonical))return;visited.add(canonical); + for(const entry of readdirSync(folder,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name))){ + assert.ok(++count<=100000,'Too many native source entries.'); + const path=join(folder,entry.name),name=logical?logical+'/'+entry.name:entry.name; + if(entry.name==='.git'||omitted.has(resolve(path)))continue; + const actual=realpathSync(path);assert.ok(inside(boundary,actual),'Native source link escapes its component.');const info=statSync(path); + if(vendorName.test(entry.name)||(info.isDirectory()&&vendorName.test(logical.split('/').at(-1)??''))||/\.(?:xcframework|framework|a|so|jar|aar)$/.test(entry.name))vendors.add(name); + if(info.isDirectory()){walk(path,name);continue;} + assert.ok(info.isFile()&&info.size<=512*1024*1024&&(total+=info.size)<=2*1024*1024*1024,'Native source inventory exceeds its bound.'); + files.push({path:name,sha256:fileDigest(path)}); + if(name.split('/').some(part=>noticeName.test(part)))notices.push(candidate(boundary,path,prefix+'/'+name)); + } + } + walk(boundary); + const vendorNotices=Object.fromEntries([...vendors].sort().map(value=>[value,notices.filter(item=>item.source.startsWith(prefix+'/'+value+'/')).map(item=>item.source)])); + return {notices,vendors:[...vendors].sort(),vendorNotices,nativeInputs:{[prefix]:{sha256:digest(JSON.stringify(files)),files:files.length,excludedBuildPaths:[...omitted].filter(path=>inside(boundary,path)).map(path=>safeRelative(relative(boundary,path))).sort()}}}; +} + // Use the actual Metro-selected files, not all installed/declared dependencies. // Nested packages never inherit another package's license or review decision. export function npmNoticeInputs(root,modulePaths){ @@ -50,45 +79,55 @@ export function npmNoticeInputs(root,modulePaths){ assert.ok(folder!==modules&&inside(modules,folder),'Selected module has no owning package.'); const lockPath='node_modules/'+safeRelative(relative(modules,folder)); assert.ok(pkg.name&&pkg.version&&lock.packages[lockPath]?.version===pkg.version,'Selected package differs from package-lock.'); - const id=`npm:${pkg.name}@${pkg.version}`,item=components.get(id)??{id,name:pkg.name,version:pkg.version,ecosystem:'npm',notices:[],vendors:[]}; + const id=`npm:${pkg.name}@${pkg.version}`,item=components.get(id)??{id,name:pkg.name,version:pkg.version,ecosystem:'npm',packagePath:lockPath,notices:[],vendors:[],vendorNotices:{}}; const moduleRelative=safeRelative(relative(folder,path)),parts=moduleRelative.split('/'); for(let i=0;ivalue.source.startsWith(lockPath+'/'+vendor+'/')).map(value=>value.source)])].sort(); components.set(id,item); } - return [...components.values()].map(item=>({...item,vendors:[...new Set(item.vendors)].sort(),notices:[...new Map(item.notices.map(value=>[value.source,value])).values()].sort((a,b)=>a.source.localeCompare(b.source))})).sort((a,b)=>a.id.localeCompare(b.id)); + return [...components.values()].map(item=>{ + const notices=[...new Map(item.notices.map(value=>[value.source,value])).values()].sort((a,b)=>a.source.localeCompare(b.source)),vendors=[...new Set(item.vendors)].sort(); + return {...item,vendors,notices}; + }).sort((a,b)=>a.id.localeCompare(b.id)); } // Native adapters must supply actual resolved coordinates and actual text, // never a POM SPDX label or an assumed parent package's permission notice. -export function nativeNoticeInput({id,name,version,ecosystem,notices,vendors=[]}){ +export function nativeNoticeInput({id,name,version,ecosystem,notices,vendors=[],vendorNotices={},nativeInputs={}}){ assert.ok(['cocoapods','maven','native'].includes(ecosystem)&&id&&name&&version); assert.ok(Array.isArray(notices)&&Array.isArray(vendors)); - return {id,name,version,ecosystem,vendors,notices:notices.map(value=>{ + return {id,name,version,ecosystem,vendors,vendorNotices,nativeInputs,notices:notices.map(value=>{ assert.ok(value.source&&!isAbsolute(value.source)&&!value.source.includes('..')); const body=noticeText(Buffer.from(value.body));assert.equal(digest(body),value.sha256);return {...value,body}; })}; } -export function resolveNoticeCoverage(components,ledger,platform){ - assert.equal(ledger.schema,1);const unresolved=[],sections=[],seen=new Set(),records=[]; +const qualificationKey=(platform,component)=>platform+':'+component; +function qualifications(ledger){ + assert.equal(ledger.schema,1);assert.ok(Array.isArray(ledger.components));const result=new Map(); + for(const value of ledger.components){assert.ok(['ios','android'].includes(value.platform)&&typeof value.component==='string'&&value.component);const key=qualificationKey(value.platform,value.component);assert.ok(!result.has(key),'Duplicate platform/component review.');result.set(key,value);} + return result; +} +export function resolveNoticeCoverage(components,ledger,platform='ios'){ + assert.ok(['ios','android'].includes(platform));const approvedRecords=qualifications(ledger),unresolved=[],sections=[],seen=new Set(),records=[]; for(const item of components){ assert.ok(!seen.has(item.id),'Duplicate resolved component.');seen.add(item.id); - const approved=ledger.components[item.id]; - const record={component:item.id,name:item.name,version:item.version,ecosystem:item.ecosystem,vendors:item.vendors,notices:item.notices.map(({source,sha256})=>({source,sha256}))};records.push(record); + const approved=approvedRecords.get(qualificationKey(platform,item.id)); + const record={component:item.id,name:item.name,version:item.version,ecosystem:item.ecosystem,vendors:item.vendors,vendorNotices:item.vendorNotices??{},nativeInputs:item.nativeInputs??{},notices:item.notices.map(({source,sha256})=>({source,sha256}))};records.push(record); const reject=reason=>unresolved.push({component:item.id,reason}); if(!item.notices.length){reject('missing-notice-text');continue;} if(!approved){reject('unreviewed-component');continue;} if(!Array.isArray(approved.reviewedVendors)||item.vendors.some(value=>!approved.reviewedVendors.includes(value))){reject('unreviewed-vendored-source');continue;} + if(item.vendors.some(value=>!(item.vendorNotices?.[value]?.length||approved.vendorNoticeSources?.[value]?.some(source=>item.notices.some(notice=>notice.source===source))))){reject('missing-vendored-notice');continue;} + if(!isDeepStrictEqual(item.nativeInputs??{},approved.nativeInputs??{})){reject('native-source-digest-mismatch');continue;} const actual=item.notices.map(({source,sha256})=>({source,sha256})).sort((a,b)=>a.source.localeCompare(b.source)); const expected=[...(approved.notices??[])].sort((a,b)=>a.source.localeCompare(b.source)); if(JSON.stringify(actual)!==JSON.stringify(expected)){reject('notice-set-or-digest-mismatch');continue;} assert.ok(typeof approved.upstream==='string'&&approved.upstream.startsWith('https://'),'Review needs a pinned upstream reference.'); - assert.ok(Array.isArray(approved.platforms)&&approved.platforms.length&&new Set(approved.platforms).size===approved.platforms.length&&approved.platforms.every(value=>['ios','android'].includes(value)),'Review needs explicit platform scope.'); - if(platform&&!approved.platforms.includes(platform)){reject('component-not-reviewed-for-platform');continue;} - const sectionId='library-'+digest(item.id).slice(0,24); + const sectionId='library-'+digest(qualificationKey(platform,item.id)).slice(0,24); record.sectionId=sectionId; - sections.push({id:sectionId,title:`${item.name} ${item.version}`,body:[...item.notices].sort((a,b)=>a.source.localeCompare(b.source)).map(value=>value.body).join('\n\n'),...(approved.platforms.length===1?{[approved.platforms[0]==='android'?'androidOnly':'iosOnly']:true}:{})}); + sections.push({id:sectionId,title:`${item.name} ${item.version}`,body:[...item.notices].sort((a,b)=>a.source.localeCompare(b.source)).map(value=>value.body).join('\n\n'),[platform==='android'?'androidOnly':'iosOnly']:true}); } return {status:unresolved.length?'INCOMPLETE':'READY',components:records,unresolved,sections}; } @@ -96,23 +135,29 @@ export function resolveNoticeCoverage(components,ledger,platform){ export function assertSourceNoticePayload(payload,coverage,ledger,fontProvenance){ assert.equal(coverage.status,'READY');assert.equal(coverage.unresolved.length,0);assert.ok(coverage.components.length); const sections=JSON.parse(noticeText(payload,16*maximumTextBytes));assert.ok(Array.isArray(sections));assert.equal(new Set(sections.map(value=>value.id)).size,sections.length); + for(const value of sections)assert.ok(typeof value.id==='string'&&typeof value.title==='string'&&typeof value.body==='string'&&Object.keys(value).every(key=>['id','title','body','androidOnly','iosOnly'].includes(key)),'Unexpected notice section fields.'); const fonts=fontProvenance.notices; - for(const [id,expected] of Object.entries(fonts)){const value=sections.find(value=>value.id===id);assert.ok(value);assert.equal(digest(value.body),expected);} + for(const [id,expected] of Object.entries(fonts)){const value=sections.find(value=>value.id===id);assert.ok(value);assert.equal(digest(value.body),expected);assertSectionPlatforms(value,fontProvenance.platforms[id]);} for(const value of coverage.sections)assert.deepEqual(sections.find(section=>section.id===value.id),value,'Actual included library differs from the committed union.'); - const known=new Map(Object.entries(ledger.components).map(([id,value])=>['library-'+digest(id).slice(0,24),value])); + const known=new Map([...qualifications(ledger)].map(([key,value])=>['library-'+digest(key).slice(0,24),value])); + for(const [id] of known)assert.ok(sections.some(value=>value.id===id),'The committed union is missing a reviewed platform section.'); for(const section of sections){ if(Object.hasOwn(fonts,section.id))continue; const approved=known.get(section.id);assert.ok(approved,'Unreviewed source notice section.');assert.equal(digest(section.body),approved.sectionSHA256,'Union text must match its reviewed provenance.'); - assert.ok(!(section.androidOnly&§ion.iosOnly)); - if(approved.platforms.length===1)assert.equal(section[approved.platforms[0]==='android'?'androidOnly':'iosOnly'],true);else assert.ok(!section.androidOnly&&!section.iosOnly); + assertSectionPlatforms(section,[approved.platform]); } return sections; } +export function assertSectionPlatforms(section,platforms){ + assert.ok(Array.isArray(platforms)&&platforms.length);const only=platforms.length===1?platforms[0]:null; + assert.deepEqual({androidOnly:section.androidOnly,iosOnly:section.iosOnly},{androidOnly:only==='android'?true:undefined,iosOnly:only==='ios'?true:undefined},'Notice platform visibility changed.'); +} // Explicit reviewed upstream copies can supply text absent from a published // AAR/JAR. They supplement, never replace, the artifact's discovered NOTICEs. -export function addReviewedNoticeFiles(root,components,ledger){ - return components.map(item=>({...item,notices:[...item.notices,...(ledger.components[item.id]?.additionalNoticeFiles??[]).map(value=>{ +export function addReviewedNoticeFiles(root,components,ledger,platform){ + const reviewed=qualifications(ledger); + return components.map(item=>({...item,notices:[...item.notices,...(reviewed.get(qualificationKey(platform,item.id))?.additionalNoticeFiles??[]).map(value=>{ safeRelative(value.file);assert.ok(value.file.startsWith('assets/notices/sources/')&&value.upstream?.startsWith('https://')); const source=candidate(root,join(root,value.file));assert.equal(source.sha256,value.sha256);return source; })].sort((a,b)=>a.source.localeCompare(b.source))})); @@ -122,7 +167,7 @@ const codeSource=path=>/\.(?:[cm]?js|jsx|tsx?|json)$/.test(path); export function capturedSource(root,supplied,ledger={generatedSources:{}}){ root=realpathSync(root);const modules=realpathSync(join(root,'node_modules')); if(Object.hasOwn(ledger.generatedSources??{},supplied)){ - const value=ledger.generatedSources[supplied];safeRelative(value.provider);assert.ok(value.provider.startsWith('node_modules/')); + const value=ledger.generatedSources[supplied];assert.equal(supplied,'__prelude__','Only the known Metro virtual prelude has a reviewed provider.');assert.equal(value.provider,'node_modules/metro/src/lib/getPreludeCode.js'); const provider=realpathSync(join(root,value.provider));assert.ok(inside(modules,provider));assert.equal(digest(readFileSync(provider)),value.sha256); return {path:supplied,generated:true,provider:value.provider,sha256:value.sha256,component:npmNoticeInputs(root,[provider])[0].id}; } @@ -155,7 +200,7 @@ export function assertFinalSourceMap(root,map,sources,ledger){ } check(map); for(const source of sources)if(source.code)assert.ok(observed.has(source.path),'Captured code is absent from the final map.'); - return {sources:observed.size,appSources:sources.filter(value=>value.app&&observed.has(value.path)).length}; + return {sources:observed.size,appSources:sources.filter(value=>value.app&&observed.has(value.path)).length,components:[...new Set([...observed].map(path=>expected.get(path).component).filter(Boolean))].sort()}; } export function assertPackagedNativeCoverage(platform,files,resolvedNative,coverage,ledger){ @@ -171,7 +216,7 @@ export function assertPackagedNativeCoverage(platform,files,resolvedNative,cover const components=[...new Set(matches.map(value=>value.component))]; if(approved){assert.ok(approved.review?.startsWith('https://'));components.push(...approved.components);} assert.ok(components.length&&components.every(id=>covered.has(id)),'Unmapped packaged native input.'); - return {path,sha256:file.sha256,components:[...new Set(components)].sort()}; + return {path,sha256:file.sha256,components:[...new Set(components)].sort(),basis:matches.length?'resolved-artifact-abi-name':'reviewed-path-to-resolved-inputs'}; }); } export function publicNoticeReport({platform,sourceCommit,entry,lockSHA256,coverage,payload}){ @@ -179,11 +224,31 @@ export function publicNoticeReport({platform,sourceCommit,entry,lockSHA256,cover if(payload)assert.equal(coverage.status,'READY'); return {schema:1,platform,sourceCommit,entry,lockSHA256,scope:payload?'precompile-input-verification':'resolved-input-discovery',status:payload?'READY':'INCOMPLETE',complete:Boolean(payload),components:coverage.components,unresolved:coverage.unresolved,...(payload?{payloadSHA256:digest(payload)}:{discoveryOnly:true})}; } -export function assertFinalNoticeEvidence({manifest,payload,finalComponents,resources,retainedResources={}}){ +// Separate review input, never the view's JSON shape or a completed coverage +// claim. Only explicitly pinned public upstream files may include body text. +export function unreviewedOSSNoticeInputs(components,ledger){ + const approved=(ledger.knownLicenseSources??[]).filter(value=>value.public===true&&value.upstream?.startsWith('https://')); + const result=[],unresolved=[];let bytes=0,count=0; + for(const item of components){ + const notices=[]; + for(const value of item.notices){ + const pin=approved.find(pin=>item.id===pin.component&&item.ecosystem===pin.ecosystem&&item.version===pin.version&&value.source===pin.source&&value.sha256===pin.sha256); + if(pin&&count<256&&bytes+Buffer.byteLength(value.body)<=4*1024*1024){ + safeRelative(value.source);assert.equal(digest(noticeText(Buffer.from(value.body))),value.sha256); + notices.push({source:value.source,sha256:value.sha256,body:value.body});bytes+=Buffer.byteLength(value.body);count++; + }else unresolved.push({componentSHA256:digest(item.id),sourceSHA256:digest(value.source),sha256:value.sha256,reason:pin?'review-artifact-limit':'public-origin-unconfirmed'}); + } + if(!item.notices.length)unresolved.push({componentSHA256:digest(item.id),reason:'missing-notice-text'}); + if(notices.length)result.push({component:item.id,version:item.version,ecosystem:item.ecosystem,notices}); + } + assert.ok(unresolved.length<=16384,'Review-input artifact exceeds its bound.'); + return {schema:1,status:'INCOMPLETE',complete:false,discoveryOnly:true,components:result,unresolved}; +} +export function assertFinalNoticeEvidence({manifest,payload,observedComponents,resolvedBuildOnly,resources,retainedResources={}}){ assert.equal(manifest.status,'READY');assert.equal(manifest.complete,true);assert.deepEqual(manifest.unresolved,[]); assert.equal(digest(payload),manifest.payloadSHA256,'Final packaged payload differs from captured source.'); const data=JSON.parse(noticeText(payload,16*maximumTextBytes));assert.ok(Array.isArray(data)); for(const component of manifest.components){const section=data.find(value=>value.id===component.sectionId);assert.ok(section?.body,'A resolved library has no readable notice section.');} - assert.deepEqual([...new Set(finalComponents)].sort(),manifest.components.map(value=>value.component).sort(),'Final runtime inventory differs from captured coverage.'); + assert.deepEqual([...new Set([...observedComponents,...resolvedBuildOnly.map(value=>value.component)])].sort(),manifest.components.map(value=>value.component).sort(),'Final evidence does not account for captured coverage.'); for(const [name,expected] of Object.entries(retainedResources))assert.equal(resources[name],expected,'An existing shipped native notice was removed or changed.'); } diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs index 11b7f4be..8ea726f8 100644 --- a/scripts/app-notices.test.mjs +++ b/scripts/app-notices.test.mjs @@ -1,20 +1,22 @@ import assert from 'node:assert/strict'; +import {execFileSync,spawnSync} from 'node:child_process'; import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; import test from 'node:test'; -import {npmNoticeInputs,nativeNoticeInput,resolveNoticeCoverage,assertSourceNoticePayload,publicNoticeReport,assertFinalNoticeEvidence,capturedSource,assertCapturedSources,assertFinalSourceMap,assertPackagedNativeCoverage,digest} from './app-notices.mjs'; -import {captureResolvedInputs} from './capture-app-notices.mjs'; +import {npmNoticeInputs,nativeNoticeInput,resolveNoticeCoverage,assertSourceNoticePayload,publicNoticeReport,assertFinalNoticeEvidence,capturedSource,assertCapturedSources,assertFinalSourceMap,assertPackagedNativeCoverage,digest,unreviewedOSSNoticeInputs} from './app-notices.mjs'; +import {captureResolvedInputs,createNoticeSeal} from './capture-app-notices.mjs'; const mit=readFileSync(new URL('../node_modules/react-native/LICENSE',import.meta.url),'utf8'); function fixture(run){ const root=mkdtempSync(join(tmpdir(),'puptime-notice-check-')); - const file=(path,body)=>{mkdirSync(join(root,path,'..'),{recursive:true});writeFileSync(join(root,path),typeof body==='string'?body:JSON.stringify(body));return join(root,path);}; + const file=(path,body)=>{mkdirSync(join(root,path,'..'),{recursive:true});writeFileSync(join(root,path),typeof body==='string'||Buffer.isBuffer(body)?body:JSON.stringify(body));return join(root,path);}; try{return run({root,file});}finally{rmSync(root,{recursive:true,force:true});} } -const review=item=>({reviewedVendors:item.vendors,platforms:['ios','android'],sectionSHA256:digest([...item.notices].sort((a,b)=>a.source.localeCompare(b.source)).map(value=>value.body).join('\n\n')),upstream:'https://example.invalid/pinned/source',notices:item.notices.map(({source,sha256})=>({source,sha256}))}); -const ledger=(...items)=>({schema:1,components:Object.fromEntries(items.map(item=>[item.id,review(item)]))}); -const font={id:'fixture-font',title:'Fixture font',body:mit},fonts={notices:{'fixture-font':digest(mit)}}; +const review=(item,platform='ios')=>({platform,component:item.id,reviewedVendors:item.vendors,nativeInputs:item.nativeInputs??{},sectionSHA256:digest([...item.notices].sort((a,b)=>a.source.localeCompare(b.source)).map(value=>value.body).join('\n\n')),upstream:'https://example.invalid/pinned/source',notices:item.notices.map(({source,sha256})=>({source,sha256}))}); +const ledger=(...items)=>({schema:1,components:items.map(item=>review(item))}); +const font={id:'fixture-font',title:'Fixture font',body:mit},fonts={notices:{'fixture-font':digest(mit)},platforms:{'fixture-font':['ios','android','web']},files:{}}; const sourcePayload=coverage=>Buffer.from(JSON.stringify([font,...coverage.sections],null,2)+'\n'); test('initial upstream inventory hashes the actual installed licence texts without certifying component coverage',()=>{ @@ -35,7 +37,7 @@ test('actual selected packages keep nested and vendored rights separate, without assert.deepEqual(resolveNoticeCoverage(inputs,ledger(parent)).unresolved,[{component:nested.id,reason:'missing-notice-text'}]); const vendor=file('node_modules/parent/vendor/separate/main.js','module.exports=1');file('node_modules/parent/vendor/separate/NOTICE','Separate upstream notice\n'); const vendored=npmNoticeInputs(root,[vendor])[0];assert.deepEqual(vendored.vendors,['vendor/separate']);assert.equal(vendored.notices.length,2); - const stale=ledger(vendored);stale.components[vendored.id].reviewedVendors=[]; + const stale=ledger(vendored);stale.components[0].reviewedVendors=[]; assert.equal(resolveNoticeCoverage([vendored],stale).status,'INCOMPLETE'); assert.equal(resolveNoticeCoverage([vendored],ledger(vendored)).status,'READY'); })); @@ -43,11 +45,11 @@ test('actual selected packages keep nested and vendored rights separate, without test('a complete package label does not excuse missing or modified actual notice/NOTICE files',()=>fixture(({root,file})=>{ file('package-lock.json',{packages:{'node_modules/qa':{version:'1.0.0'}}});file('node_modules/qa/package.json',{name:'qa',version:'1.0.0',license:'MIT'}); const source=file('node_modules/qa/main.js',''); - const missing=npmNoticeInputs(root,[source]);assert.equal(resolveNoticeCoverage(missing,{schema:1,components:{}}).unresolved[0].reason,'missing-notice-text'); + const missing=npmNoticeInputs(root,[source]);assert.equal(resolveNoticeCoverage(missing,{schema:1,components:[]}).unresolved[0].reason,'missing-notice-text'); file('node_modules/qa/LICENSE',mit);file('node_modules/qa/NOTICE','Preserve the vendored attribution\n'); file('node_modules/qa/LICENSE-MIT',mit);file('node_modules/qa/LICENSES/Vendor-A.txt','Distinct actual vendor licence\n');file('node_modules/qa/THIRD_PARTY_NOTICES.md','More attribution\n'); const input=npmNoticeInputs(root,[source])[0],approved=ledger(input);assert.equal(input.notices.length,5); - approved.components[input.id].notices.pop();assert.equal(resolveNoticeCoverage([input],approved).status,'INCOMPLETE'); + approved.components[0].notices.pop();assert.equal(resolveNoticeCoverage([input],approved).status,'INCOMPLETE'); const before=ledger(input);file('node_modules/qa/LICENSE',mit+'modified'); assert.equal(resolveNoticeCoverage(npmNoticeInputs(root,[source]),before).status,'INCOMPLETE'); file('node_modules/qa/package.json',{name:'qa',version:'2.0.0'});assert.throws(()=>npmNoticeInputs(root,[source])); @@ -55,13 +57,13 @@ test('a complete package label does not excuse missing or modified actual notice test('collection remains explicitly incomplete and cannot create a release payload or disclose filesystem paths',()=>{ const item=nativeNoticeInput({id:'maven:qa:library:1',name:'qa:library',version:'1',ecosystem:'maven',notices:[{source:'aar:qa-library.aar!META-INF/LICENSE',body:mit,sha256:digest(mit)}]}); - const missing=resolveNoticeCoverage([item],{schema:1,components:{}});assert.equal(missing.status,'INCOMPLETE');assert.throws(()=>assertSourceNoticePayload(sourcePayload(missing),missing,{schema:1,components:{}},fonts)); + const missing=resolveNoticeCoverage([item],{schema:1,components:[]});assert.equal(missing.status,'INCOMPLETE');assert.throws(()=>assertSourceNoticePayload(sourcePayload(missing),missing,{schema:1,components:[]},fonts)); const ready=resolveNoticeCoverage([item],ledger(item)); const report=publicNoticeReport({platform:'android',sourceCommit:'a'.repeat(40),entry:'index.ts',lockSHA256:'b'.repeat(64),coverage:ready}); assert.equal(report.status,'INCOMPLETE');assert.equal(report.complete,false);assert.equal(report.payloadSHA256,undefined);assert.equal(report.discoveryOnly,true); assert.ok(!JSON.stringify(report).includes(tmpdir())); assert.throws(()=>nativeNoticeInput({...item,notices:[{...item.notices[0],source:'/private/owner/credential'}]})); - assert.throws(()=>assertSourceNoticePayload(Buffer.from(JSON.stringify([font])),{status:'READY',components:[],unresolved:[],sections:[]},{schema:1,components:{}},fonts)); + assert.throws(()=>assertSourceNoticePayload(Buffer.from(JSON.stringify([font])),{status:'READY',components:[],unresolved:[],sections:[]},{schema:1,components:[]},fonts)); }); test('final evidence requires the exact readable payload, runtime coverage and every retained native notice',()=>{ @@ -69,18 +71,22 @@ test('final evidence requires the exact readable payload, runtime coverage and e const coverage=resolveNoticeCoverage([item],ledger(item)),payload=sourcePayload(coverage); const manifest=publicNoticeReport({platform:'ios',sourceCommit:'a'.repeat(40),entry:'index.ts',lockSHA256:'b'.repeat(64),coverage,payload}); assertSourceNoticePayload(payload,coverage,ledger(item),fonts); - const input={manifest,payload,finalComponents:[item.id],resources:{'META-INF/existing/NOTICE':'c'.repeat(64)},retainedResources:{'META-INF/existing/NOTICE':'c'.repeat(64)}}; + const input={manifest,payload,observedComponents:[item.id],resolvedBuildOnly:[],resources:{'META-INF/existing/NOTICE':'c'.repeat(64)},retainedResources:{'META-INF/existing/NOTICE':'c'.repeat(64)}}; assertFinalNoticeEvidence(input); assert.throws(()=>assertFinalNoticeEvidence({...input,payload:Buffer.from('[]')})); - assert.throws(()=>assertFinalNoticeEvidence({...input,finalComponents:[item.id,'native:unmapped@1']})); + assert.throws(()=>assertFinalNoticeEvidence({...input,observedComponents:[item.id,'native:unmapped@1']})); assert.throws(()=>assertFinalNoticeEvidence({...input,resources:{}})); assert.throws(()=>assertFinalNoticeEvidence({...input,manifest:{...manifest,complete:false}})); const changed=Buffer.from(payload.toString().replace('library-','missing-')); assert.throws(()=>assertFinalNoticeEvidence({...input,payload:changed,manifest:{...manifest,payloadSHA256:digest(changed)}})); assert.throws(()=>assertSourceNoticePayload(changed,coverage,ledger(item),fonts)); - const drift=ledger(item);drift.components[item.id].sectionSHA256='d'.repeat(64);assert.throws(()=>assertSourceNoticePayload(payload,coverage,drift,fonts)); - const wrongPlatform=ledger(item);wrongPlatform.components[item.id].platforms=['android']; - assert.equal(resolveNoticeCoverage([item],wrongPlatform,'ios').unresolved[0].reason,'component-not-reviewed-for-platform'); + const drift=ledger(item);drift.components[0].sectionSHA256='d'.repeat(64);assert.throws(()=>assertSourceNoticePayload(payload,coverage,drift,fonts)); + const wrongPlatform=ledger(item);wrongPlatform.components[0].platform='android'; + assert.equal(resolveNoticeCoverage([item],wrongPlatform,'ios').unresolved[0].reason,'unreviewed-component'); + for(const key of ['iosOnly','androidOnly']){ + const hidden=JSON.parse(payload);hidden[0][key]=true; + assert.throws(()=>assertSourceNoticePayload(Buffer.from(JSON.stringify(hidden)),coverage,ledger(item),fonts)); + } }); test('worktree source proof rejects a foreign checkout and stale final sourcesContent even with identical package coverage',()=>fixture(({root,file})=>{ @@ -110,10 +116,56 @@ test('unknown packaged native libraries cannot be certified by a complete JS inv test('real resolved-input collection leaves missing native/generated review incomplete without changing the tracked payload',()=>fixture(({root,file})=>{ file('package-lock.json',{packages:{'node_modules/qa':{version:'1'}}});file('node_modules/qa/package.json',{name:'qa',version:'1'});file('node_modules/qa/LICENSE',mit); const path=file('node_modules/qa/index.js','module.exports=1;'); - file('scripts/app-notice-provenance.json',{schema:1,components:{},generatedSources:{}});file('scripts/font-icon-provenance.json',fonts); + file('scripts/app-notice-provenance.json',{schema:1,components:[],generatedSources:{}});file('scripts/font-icon-provenance.json',fonts); const payload=file('assets/notices/third_party_notices.json',JSON.stringify([font])),before=readFileSync(payload); const result=captureResolvedInputs(root,'ios',[path,'__prelude__']); assert.equal(result.coverage.status,'INCOMPLETE');assert.ok(result.coverage.unresolved.some(value=>value.reason==='missing-resolved-pod-inputs')); assert.ok(result.coverage.unresolved.some(value=>value.reason==='unreviewed-generated-source')); assert.ok(readFileSync(payload).equals(before));assert.ok(!JSON.stringify(result).includes(root)); })); + +test('public review serialization includes only exact pinned public notice bodies and never private-origin data',()=>{ + const value=nativeNoticeInput({id:'native:private-component@1',name:'private-component',version:'1',ecosystem:'native',notices:[{source:'private/NOTICE',body:'private customer information',sha256:digest('private customer information')}]}); + const publicValue={id:'npm:qa@1',name:'qa',version:'1',ecosystem:'npm',notices:[{source:'node_modules/qa/LICENSE',body:mit,sha256:digest(mit)}],vendors:[]}; + const nativePublic=['cocoapods','maven'].map(ecosystem=>nativeNoticeInput({id:ecosystem+':public-qa@1',name:'public-qa',version:'1',ecosystem,notices:[{source:ecosystem+':public-qa/NOTICE',body:mit,sha256:digest(mit)}]})); + const knownLicenseSources=[publicValue,...nativePublic].map(item=>({public:true,component:item.id,ecosystem:item.ecosystem,version:item.version,source:item.notices[0].source,sha256:digest(mit),upstream:'https://example.invalid/pinned/LICENSE'})); + const data=unreviewedOSSNoticeInputs([publicValue,...nativePublic,value],{knownLicenseSources}); + assert.equal(data.status,'INCOMPLETE');assert.equal(data.complete,false);assert.equal(data.discoveryOnly,true);assert.equal(data.components[0].notices[0].body,mit);assert.equal(data.components.length,3);assert.equal(data.unresolved.length,1); + for(const privateText of ['private-component','private/NOTICE','private customer information'])assert.ok(!JSON.stringify(data).includes(privateText)); + const changed={...publicValue,notices:[{...publicValue.notices[0],body:'changed private bytes',sha256:digest('changed private bytes')}]}; + const blocked=unreviewedOSSNoticeInputs([changed],{knownLicenseSources}); + assert.equal(blocked.components.length,0);assert.ok(!JSON.stringify(blocked).includes('changed private bytes')); +}); + +test('actual verify CLI derives final components from map/native inputs and rejects missing assets, extras and dirty HEAD inputs',()=>fixture(({root,file})=>{ + const runGit=(...args)=>execFileSync('git',['-c','core.hooksPath=/dev/null','-c','commit.gpgsign=false',...args],{cwd:root,stdio:['ignore','pipe','pipe']}); + file('.gitignore','node_modules/\nbuild/\n');file('package.json',{name:'fixture',version:'1'}); + const app={expo:{plugins:[['expo-asset',{assets:['./assets/notices/third_party_notices.json']}]]}};file('app.json',app); + const lock={packages:{'node_modules/qa':{version:'1'},'node_modules/assets':{version:'1'},'node_modules/extra':{version:'1'}}};file('package-lock.json',lock); + for(const name of ['qa','assets','extra']){file(`node_modules/${name}/package.json`,{name,version:'1'});file(`node_modules/${name}/LICENSE`,mit);} + const appCode='export const app=1;\n',moduleCode='module.exports=42;\n'; + const appPath=file('index.ts',appCode),modulePath=file('node_modules/qa/index.js',moduleCode),assetPath=file('node_modules/assets/font.ttf','synthetic font bytes');file('node_modules/extra/index.js','module.exports=0;'); + file('scripts/font-icon-provenance.json',fonts);file('assets/notices/third_party_notices.json','[]'); + const aar=file('build/source.aar',''),staticJar=file('build/static.jar',''); + execFileSync('python3',['-c',`import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:z.writestr('LICENSE',sys.argv[3]);z.writestr('jni/arm64-v8a/libqa.so',b'\\x7fELFcontrolled')\nwith zipfile.ZipFile(sys.argv[2],'w') as z:z.writestr('LICENSE',sys.argv[3])`,aar,staticJar,mit]); + const gradle=file('build/runtime-inputs.json',{schema:1,configuration:'releaseRuntimeClasspath',components:[{group:'qa',name:'native',version:'1',artifacts:[{file:aar,sha256:digest(readFileSync(aar))}]},{group:'qa',name:'static',version:'1',artifacts:[{file:staticJar,sha256:digest(readFileSync(staticJar))}]}]}); + const empty={schema:1,components:[],knownLicenseSources:[],retainedAndroidResources:{}};file('scripts/app-notice-provenance.json',empty); + const paths=[appPath,modulePath,assetPath,join(root,'assets/notices/third_party_notices.json')]; + const initial=captureResolvedInputs(root,'android',paths,gradle),approved={...empty,components:initial.noticeInputs.map(item=>review(item,'android'))};file('scripts/app-notice-provenance.json',approved); + const coverage=resolveNoticeCoverage(initial.noticeInputs,approved,'android');file('assets/notices/third_party_notices.json',sourcePayload(coverage)); + runGit('init','--quiet');runGit('add','.');runGit('-c','user.name=Notice test','-c','user.email=notice-test@example.invalid','commit','--quiet','-m','Synthetic notice fixture'); + const capture=captureResolvedInputs(root,'android',paths,gradle),seal=createNoticeSeal(root,'android',capture);file('build/notices/android/seal.json',seal); + for(const name of ['app.json','package-lock.json']){const before=readFileSync(join(root,name));file(name,Buffer.concat([before,Buffer.from(' ')]));assert.throws(()=>createNoticeSeal(root,'android',capture));file(name,before);} + assert.throws(()=>createNoticeSeal(join(root,'node_modules'), 'android',capture)); + const sourceMap={version:3,sources:['index.ts','node_modules/qa/index.js','node_modules/assets/font.ttf','assets/notices/third_party_notices.json'],sourcesContent:[appCode,moduleCode,'',readFileSync(join(root,'assets/notices/third_party_notices.json'),'utf8')]}; + const map=file('build/actual.map',sourceMap),bundle=file('build/index.android.bundle','controlled compiler output'),apk=join(root,'build/final.apk'); + const pack=(extra=false)=>execFileSync('python3',['-c',`import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:\n z.write(sys.argv[2],'assets/third_party_notices.json');z.write(sys.argv[3],'assets/index.android.bundle');z.writestr('lib/arm64-v8a/libqa.so',b'\\x7fELFcontrolled');z.writestr('assets/font.ttf','synthetic font bytes')\n if sys.argv[4]=='yes':z.writestr('assets/unknown-executable',b'\\xcf\\xfa\\xed\\xfecontrolled')`,apk,join(root,'assets/notices/third_party_notices.json'),bundle,extra?'yes':'no']); + const verify=()=>spawnSync(process.execPath,[fileURLToPath(new URL('./capture-app-notices.mjs',import.meta.url)),'verify','android','--gradle-input',gradle,'--archive',apk,'--bundle',bundle,'--source-map',map],{cwd:root,encoding:'utf8',timeout:30000}); + pack();assert.equal(verify().status,0); + const receipt=JSON.parse(readFileSync(join(root,'build/notices/android/final-apk.json')));assert.equal(receipt.inventory.independentFinalComponentInventory,false); + assert.deepEqual(receipt.inventory.resolvedBuildOnly,[{component:'maven:qa:static@1',basis:'reinspected-gradle-artifacts-and-source'}]); + const missing={...sourceMap,sources:sourceMap.sources.filter((_,i)=>i!==2),sourcesContent:sourceMap.sourcesContent.filter((_,i)=>i!==2)};file('build/actual.map',missing); + assert.equal(verify().status,1,'Captured asset-only package cannot be supplied back as an observed final component.'); + const extra={...sourceMap,sources:[...sourceMap.sources,'node_modules/extra/index.js'],sourcesContent:[...sourceMap.sourcesContent,'module.exports=0;']};file('build/actual.map',extra);assert.equal(verify().status,1); + file('build/actual.map',sourceMap);pack(true);assert.equal(verify().status,1,'Unmapped extensionless Mach-O must fail through the production CLI.'); +})); diff --git a/scripts/capture-app-notices.mjs b/scripts/capture-app-notices.mjs index 6138a934..584f3136 100644 --- a/scripts/capture-app-notices.mjs +++ b/scripts/capture-app-notices.mjs @@ -5,13 +5,13 @@ import {chmodSync,existsSync,lstatSync,mkdirSync,readFileSync,realpathSync,rmSyn import {createRequire} from 'node:module'; import {dirname,join,relative,resolve,sep} from 'node:path'; import {fileURLToPath} from 'node:url'; -import {addReviewedNoticeFiles,assertCapturedSources,assertFinalNoticeEvidence,assertFinalSourceMap,assertPackagedNativeCoverage,assertSourceNoticePayload,capturedSource,digest,npmNoticeInputs,publicNoticeReport,resolveNoticeCoverage} from './app-notices.mjs'; +import {addReviewedNoticeFiles,assertCapturedSources,assertFinalNoticeEvidence,assertFinalSourceMap,assertPackagedNativeCoverage,assertSourceNoticePayload,capturedSource,digest,npmNoticeInputs,noticeText,publicNoticeReport,resolveNoticeCoverage,unreviewedOSSNoticeInputs} from './app-notices.mjs'; import {gradleNoticeInputs,inspectNoticeArchive,podNoticeInputs,unionNoticeInputs} from './native-notice-inputs.mjs'; import {installTaskCleanup} from './native-task-cleanup.mjs'; export const payloadPath='assets/notices/third_party_notices.json'; const ledgerPath='scripts/app-notice-provenance.json',fontPath='scripts/font-icon-provenance.json',entry='index.ts'; -const readJSON=path=>{assert.ok(statSync(path).size<=128*1024*1024);return JSON.parse(readFileSync(path,'utf8'));}; +const readJSON=path=>{assert.ok(statSync(path).size<=128*1024*1024);return JSON.parse(noticeText(readFileSync(path),128*1024*1024));}; const git=(root,...args)=>execFileSync('git',args,{cwd:root,maxBuffer:32*1024*1024,stdio:['ignore','pipe','pipe']}); const committed=(root,path)=>git(root,'show','HEAD:'+path); function strictSource(root,path){const bytes=readFileSync(join(root,path));assert.ok(bytes.equals(committed(root,path)),'Notice source is not committed.');return bytes;} @@ -20,6 +20,7 @@ function nativeInputs(root,platform,gradleInput){return platform==='ios'?podNoti // Given the actual Metro-selected paths, capture component/own-source evidence. // Injecting paths permits meaningful fixture checks without starting a bundler. export function captureResolvedInputs(root,platform,paths,gradleInput){ + assert.ok(paths.length<=100000,'Metro graph exceeds the capture bound.'); const ledger=readJSON(join(root,ledgerPath)),fonts=readJSON(join(root,fontPath)),unresolved=[],sources=[],physical=[]; for(const path of new Set(paths)){ if(path==='__prelude__'&&!Object.hasOwn(ledger.generatedSources??{},path)){unresolved.push({component:'metro:__prelude__',reason:'unreviewed-generated-source'});continue;} @@ -27,10 +28,11 @@ export function captureResolvedInputs(root,platform,paths,gradleInput){ physical.push(join(root,value.generated?value.provider:value.path)); } const native=nativeInputs(root,platform,gradleInput); - const components=addReviewedNoticeFiles(root,unionNoticeInputs(npmNoticeInputs(root,physical),native.components),ledger); + const components=addReviewedNoticeFiles(root,unionNoticeInputs(npmNoticeInputs(root,physical),native.components),ledger,platform); + assert.ok(components.length<=2048&&components.reduce((bytes,item)=>bytes+item.notices.reduce((sum,value)=>sum+Buffer.byteLength(value.body),0),0)<=16*1024*1024,'Notice inputs exceed the review bound.'); const coverage=resolveNoticeCoverage(components,ledger,platform);coverage.unresolved.push(...unresolved,...native.unresolved); if(coverage.unresolved.length)coverage.status='INCOMPLETE'; - return {ledger,fonts,coverage,sources:sources.sort((a,b)=>a.path.localeCompare(b.path)),native}; + return {ledger,fonts,coverage,sources:sources.sort((a,b)=>a.path.localeCompare(b.path)),native,noticeInputs:components}; } async function metroPaths(root,platform,privateRoot){ @@ -43,19 +45,21 @@ async function metroPaths(root,platform,privateRoot){ let server;const cleanup=installTaskCleanup(async()=>{await server?.end();}); // Metro diagnostics can include absolute developer paths/source excerpts. // This standalone discovery process emits finite summaries/reports only. - const stdout=process.stdout.write,stderr=process.stderr.write;process.stdout.write=process.stderr.write=()=>true; + const stdout=process.stdout.write,stderr=process.stderr.write; + process.stdout.write=process.stderr.write=(_chunk,encoding,callback)=>{const done=typeof encoding==='function'?encoding:callback;if(done)queueMicrotask(done);return true;}; try{ const result=await createMetroServerAndBundleRequestAsync(root,{entryFile:entry,platform,dev:false,minify:false,maxWorkers:2,resetCache:false});server=result.server; return await server.getOrderedDependencyPaths(result.bundleRequest); }finally{try{await cleanup.run();}finally{cleanup.dispose();process.stdout.write=stdout;process.stderr.write=stderr;}} } -function assertReviewedSource(root,capture){ +export function assertReviewedSource(root,capture){ + assert.equal(realpathSync(root),realpathSync(git(root,'rev-parse','--show-toplevel').toString().trim()),'Use the canonical checkout root.'); const {ledger,fonts,sources,coverage}=capture; assertCapturedSources(root,sources,path=>committed(root,path)); - for(const path of [ledgerPath,fontPath,payloadPath])strictSource(root,path); + for(const path of ['package.json','package-lock.json','app.json',ledgerPath,fontPath,payloadPath])strictSource(root,path); for(const [path,expected] of Object.entries(fonts.files))assert.equal(digest(readFileSync(join(root,path))),expected,'Installed font input drifted.'); - for(const component of Object.values(ledger.components))for(const item of component.additionalNoticeFiles??[])strictSource(root,item.file); + for(const component of ledger.components)for(const item of component.additionalNoticeFiles??[])strictSource(root,item.file); const payload=readFileSync(join(root,payloadPath));assertSourceNoticePayload(payload,coverage,ledger,fonts); assert.ok(sources.some(value=>value.path===payloadPath&&value.sha256===digest(payload)),'The offline view must import the same committed resource.'); const app=readJSON(join(root,'app.json')).expo; @@ -72,13 +76,34 @@ function outputDirectory(root,platform){ return path; } function write(path,value){writeFileSync(path,JSON.stringify(value,null,2)+'\n',{mode:0o600});chmodSync(path,0o600);} -function inputHashes(root,capture){return {...capture.native.inputHashes,packageLock:digest(readFileSync(join(root,'package-lock.json'))),ledger:digest(readFileSync(join(root,ledgerPath))),fontProvenance:digest(readFileSync(join(root,fontPath)))};} +function inputHashes(root,capture){return {...capture.native.inputHashes,packageManifest:digest(readFileSync(join(root,'package.json'))),appConfig:digest(readFileSync(join(root,'app.json'))),packageLock:digest(readFileSync(join(root,'package-lock.json'))),ledger:digest(readFileSync(join(root,ledgerPath))),fontProvenance:digest(readFileSync(join(root,fontPath)))};} +export function createNoticeSeal(root,platform,capture){ + const payload=assertReviewedSource(root,capture),sourceCommit=git(root,'rev-parse','HEAD').toString().trim(); + return {...publicNoticeReport({platform,sourceCommit,entry,lockSHA256:digest(readFileSync(join(root,'package-lock.json'))),coverage:capture.coverage,payload}),sources:capture.sources,inputHashes:inputHashes(root,capture)}; +} const resourcePaths={ apk:{platform:'android',payload:'assets/third_party_notices.json',bundle:'assets/index.android.bundle'}, aab:{platform:'android',payload:'base/assets/third_party_notices.json',bundle:'base/assets/index.android.bundle'}, ipa:{platform:'ios',payload:'Payload/Puptime.app/third_party_notices.json',bundle:'Payload/Puptime.app/main.jsbundle'}, }; +function finalNoticeProof(root,platform,seal,capture,payload,options){ + const kind=options['--archive'].split('.').at(-1).toLowerCase(),paths=resourcePaths[kind];assert.equal(paths?.platform,platform); + const mapEvidence=assertFinalSourceMap(root,readJSON(options['--source-map']),seal.sources,capture.ledger); + const archive=inspectNoticeArchive(options['--archive'],paths.payload,paths.bundle);assert.ok(archive.payload&&archive.bundle); + assert.ok(Buffer.from(archive.payload.base64,'base64').equals(payload));assert.equal(archive.bundle.sha256,digest(readFileSync(options['--bundle'])),'Final bundle differs from the mapped build output.'); + const native=assertPackagedNativeCoverage(platform,archive.nativeFiles,capture.native.nativeFiles,capture.coverage,capture.ledger); + const observedComponents=[...new Set([...mapEvidence.components,...native.flatMap(value=>value.components)])].sort(); + // Static/DEX ownership cannot be read independently from these final bytes. + // Reinspect actual resolved inputs after packaging and label that exact limit. + const resolved=new Map(capture.native.components.map(value=>[value.id,{component:value.id,basis:platform==='ios'?'reinspected-pod-lock-and-source':'reinspected-gradle-artifacts-and-source'}])); + for(const value of capture.sources.filter(value=>value.generated))resolved.set(value.component,{component:value.component,basis:'locked-generated-metro-provider'}); + const resolvedBuildOnly=[...resolved.values()].filter(value=>!observedComponents.includes(value.component)).sort((a,b)=>a.component.localeCompare(b.component)); + const resources=Object.fromEntries(archive.notices.map(value=>[kind==='aab'?value.source.replace(/^base\/root\//,''):value.source,value.sha256])); + assertFinalNoticeEvidence({manifest:seal,payload,observedComponents,resolvedBuildOnly,resources,retainedResources:platform==='android'?capture.ledger.retainedAndroidResources:{}}); + return {schema:1,scope:'final-artifact-verification',status:'READY',sourceCommit:seal.sourceCommit,platform,payloadSHA256:digest(payload),archiveSHA256:digest(readFileSync(options['--archive'])),bundleSHA256:archive.bundle.sha256,sourceMapSHA256:digest(readFileSync(options['--source-map'])),mapEvidence,native,inventory:{observedComponents,resolvedBuildOnly,independentFinalComponentInventory:false,postPackageResolvedInputReinspection:true,postBuildResolutionRegenerated:false},complete:true}; +} + export async function main(args=process.argv.slice(2)){ const [mode,platform,...tail]=args;assert.ok(['collect','seal','verify'].includes(mode)&&['ios','android'].includes(platform),'Use collect|seal|verify ios|android.'); const options={};assert.equal(tail.length%2,0); @@ -91,15 +116,8 @@ export async function main(args=process.argv.slice(2)){ const capture=captureResolvedInputs(root,platform,seal.sources.map(value=>value.generated?value.path:join(root,value.path)),options['--gradle-input']); assert.deepEqual(capture.sources,seal.sources);assert.deepEqual(inputHashes(root,capture),seal.inputHashes);assert.deepEqual(capture.coverage.components,seal.components); const payload=assertReviewedSource(root,capture);assert.equal(digest(payload),seal.payloadSHA256); - stage='final-source';assert.ok(options['--source-map']&&options['--bundle']&&options['--archive']); - const sourceMap=readJSON(options['--source-map']),mapEvidence=assertFinalSourceMap(root,sourceMap,seal.sources,capture.ledger); - const archivePath=options['--archive'],paths=resourcePaths[kind];assert.equal(paths?.platform,platform); - stage='archive';const archive=inspectNoticeArchive(archivePath,paths.payload,paths.bundle);assert.ok(archive.payload&&archive.bundle); - assert.ok(Buffer.from(archive.payload.base64,'base64').equals(payload));assert.equal(archive.bundle.sha256,digest(readFileSync(options['--bundle'])),'Final bundle differs from the mapped build output.'); - const native=assertPackagedNativeCoverage(platform,archive.nativeFiles,capture.native.nativeFiles,capture.coverage,capture.ledger); - const resources=Object.fromEntries(archive.notices.map(value=>[kind==='aab'?value.source.replace(/^base\/root\//,''):value.source,value.sha256])); - assertFinalNoticeEvidence({manifest:seal,payload,finalComponents:capture.coverage.components.map(value=>value.component),resources,retainedResources:platform==='android'?capture.ledger.retainedAndroidResources:{}}); - write(join(out,'final-'+kind+'.json'),{schema:1,scope:'final-artifact-verification',status:'READY',sourceCommit,platform,payloadSHA256:digest(payload),archiveSHA256:digest(readFileSync(archivePath)),bundleSHA256:archive.bundle.sha256,sourceMapSHA256:digest(readFileSync(options['--source-map'])),mapEvidence,native,complete:true}); + stage='final-artifact';assert.ok(options['--source-map']&&options['--bundle']&&options['--archive']); + write(join(out,'final-'+kind+'.json'),finalNoticeProof(root,platform,seal,capture,payload,options)); console.log('Notices final verification: READY.');return; } if(mode==='seal'){rmSync(join(out,'seal.json'),{force:true});for(const kind of Object.keys(resourcePaths))rmSync(join(out,'final-'+kind+'.json'),{force:true});} @@ -107,9 +125,10 @@ export async function main(args=process.argv.slice(2)){ const paths=await metroPaths(root,platform,privateRoot);stage='native';const capture=captureResolvedInputs(root,platform,paths,options['--gradle-input']); const report=publicNoticeReport({platform,sourceCommit,entry,lockSHA256:digest(readFileSync(join(root,'package-lock.json'))),coverage:capture.coverage}); write(join(out,'discovery.json'),{...report,sources:capture.sources,inputHashes:inputHashes(root,capture)}); + write(join(privateRoot,'unreviewed-inputs.json'),{schema:1,status:'INCOMPLETE',complete:false,discoveryOnly:true,components:capture.noticeInputs}); + const review=join(out,'review');mkdirSync(review,{recursive:true,mode:0o700});write(join(review,'unreviewed-oss-notice-inputs.json'),unreviewedOSSNoticeInputs(capture.noticeInputs,capture.ledger)); if(mode==='collect'){console.log(`Notices discovery: INCOMPLETE (${capture.coverage.unresolved.length} unresolved inputs); no release payload generated.`);return;} - stage='seal';const payload=assertReviewedSource(root,capture); - write(join(out,'seal.json'),{...publicNoticeReport({...report,coverage:capture.coverage,payload}),sources:capture.sources,inputHashes:inputHashes(root,capture)}); + stage='seal';write(join(out,'seal.json'),createNoticeSeal(root,platform,capture)); console.log('Notices precompile verification: READY.'); }catch{ write(join(out,'failure.json'),{schema:1,status:'INCOMPLETE',complete:false,sourceCommit,platform,stage}); diff --git a/scripts/font-icon-provenance.json b/scripts/font-icon-provenance.json index 620e4922..671d879b 100644 --- a/scripts/font-icon-provenance.json +++ b/scripts/font-icon-provenance.json @@ -23,6 +23,14 @@ "expo-vector-icons": "a5416f6b586be51823285bc9a389bfd756eef80cfb7616ea90aaa11bae6189e1", "react-native-vector-icons": "000a66d1bc9e206b5963ba9370cd03350a4b26e205b5880e8e0f882a51951178" }, + "platforms": { + "red-hat": ["ios", "android", "web"], + "material-community-icons": ["ios", "android", "web"], + "roboto": ["android"], + "apache-2": ["ios", "android", "web"], + "expo-vector-icons": ["ios", "android", "web"], + "react-native-vector-icons": ["ios", "android", "web"] + }, "upstream": { "materialCommunityIcons": { "version": "7.4.47", diff --git a/scripts/native-notice-inputs.mjs b/scripts/native-notice-inputs.mjs index e548a013..c483a2f2 100644 --- a/scripts/native-notice-inputs.mjs +++ b/scripts/native-notice-inputs.mjs @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; import {execFileSync} from 'node:child_process'; import {existsSync,readFileSync,readdirSync,realpathSync} from 'node:fs'; -import {basename,join,relative,resolve,sep} from 'node:path'; +import {basename,dirname,join,relative,resolve,sep} from 'node:path'; import {createRequire} from 'node:module'; -import {digest,nativeNoticeInput,npmNoticeInputs} from './app-notices.mjs'; +import {digest,nativeNoticeInput,npmNoticeInputs,nativeSourceNotices,noticeText} from './app-notices.mjs'; const require=createRequire(import.meta.url),archiveReader=new URL('./notice-archive.py',import.meta.url); export function inspectNoticeArchive(path,payloadPath,bundlePath){ @@ -12,7 +12,7 @@ export function inspectNoticeArchive(path,payloadPath,bundlePath){ export function podNoticeInputs(root){ const lockPath=join(root,'ios/Podfile.lock'),support=join(root,'ios/Pods/Target Support Files'); if(!existsSync(lockPath)||!existsSync(support))return {components:[],unresolved:[{component:'native:ios',reason:'missing-resolved-pod-inputs'}],nativeFiles:[]}; - const lock=require('yaml').parse(readFileSync(lockPath,'utf8')),plist=require('@expo/plist').default,versions=new Map(),texts=new Map(); + const lock=require('yaml').parse(noticeText(readFileSync(lockPath),16*1024*1024)),plist=require('@expo/plist').default,versions=new Map(),texts=new Map(),acknowledgements=[]; for(const row of lock.PODS??[]){ const value=typeof row==='string'?row:Object.keys(row)[0],match=value.match(/^([^\s(]+) \(([^\s)]+)/);assert.ok(match,'Unsupported resolved pod entry.'); const name=match[1].split('/')[0],version=match[2];assert.ok(!versions.has(name)||versions.get(name)===version);versions.set(name,version); @@ -20,7 +20,8 @@ export function podNoticeInputs(root){ assert.ok(versions.size,'Missing resolved pods.'); for(const target of readdirSync(support).sort()){ const path=join(support,target,target+'-acknowledgements.plist');if(!existsSync(path))continue; - for(const row of plist.parse(readFileSync(path,'utf8')).PreferenceSpecifiers??[]){ + const bytes=readFileSync(path);acknowledgements.push({source:relative(root,path).split(sep).join('/'),sha256:digest(bytes)}); + for(const row of plist.parse(noticeText(bytes,16*1024*1024)).PreferenceSpecifiers??[]){ if(!versions.has(row.Title)||typeof row.FooterText!=='string')continue; const list=texts.get(row.Title)??[],body=row.FooterText; if(!list.some(value=>value.sha256===digest(body)))list.push({source:`cocoapods:${target}/${row.Title}`,body,sha256:digest(body)}); @@ -29,31 +30,27 @@ export function podNoticeInputs(root){ } const unresolved=[],components=[]; for(const [name,version] of versions){ - const vendors=[],notices=texts.get(name)??[],external=lock['EXTERNAL SOURCES']?.[name]?.[':path']; + const notices=texts.get(name)??[],external=lock['EXTERNAL SOURCES']?.[name]?.[':path'];let scan={vendors:[],vendorNotices:{},nativeInputs:{},notices:[]}; const source=external?resolve(root,'ios',external):join(root,'ios/Pods',name); if(!existsSync(source))unresolved.push({component:`cocoapods:${name}@${version}`,reason:'missing-resolved-pod-source'}); - else{ - const boundary=realpathSync(source),project=realpathSync(root),modules=existsSync(join(root,'node_modules'))?realpathSync(join(root,'node_modules')):null; - assert.ok(boundary.startsWith(project+sep)||(modules&&boundary.startsWith(modules+sep)),'Native source resolved outside this checkout and its installed modules.'); - const entries=readdirSync(boundary,{recursive:true,withFileTypes:true});assert.ok(entries.length<=100000); - for(const entry of entries){ - const path=join(entry.parentPath??entry.path,entry.name),logical=relative(boundary,path).split(sep).join('/'); - if(/\.(?:xcframework|framework|a|so)$/.test(entry.name))vendors.push(logical); - if(!entry.isFile()||!logical.split('/').some(part=>/^(licen[cs]es?|notices?|copying|copyright|third[._-]?party[._-]?(?:licen[cs]es?|notices?))([._-].*)?$/i.test(part)))continue; - assert.ok(realpathSync(path).startsWith(boundary+sep));const body=readFileSync(path,'utf8'); - notices.push({source:`cocoapods:${name}/${logical}`,body,sha256:digest(body)}); - } - } - components.push(nativeNoticeInput({id:`cocoapods:${name}@${version}`,name,version,ecosystem:'cocoapods',notices:notices.sort((a,b)=>a.source.localeCompare(b.source)),vendors:vendors.sort()})); + else scan=nativeSourceNotices(root,source,`cocoapods:${name}`); + components.push(nativeNoticeInput({id:`cocoapods:${name}@${version}`,name,version,ecosystem:'cocoapods',...scan,notices:[...notices,...scan.notices].sort((a,b)=>a.source.localeCompare(b.source))})); } - return {components,unresolved,nativeFiles:[],inputHashes:{podLock:digest(readFileSync(lockPath))}}; + return {components,unresolved,nativeFiles:[],inputHashes:{podLock:digest(readFileSync(lockPath)),podAcknowledgements:digest(JSON.stringify(acknowledgements))}}; } export function gradleNoticeInputs(root,inputFile){ if(!inputFile||!existsSync(inputFile))return {components:[],unresolved:[{component:'native:android',reason:'missing-resolved-gradle-inputs'}],nativeFiles:[]}; const input=JSON.parse(readFileSync(inputFile));assert.equal(input.schema,1);assert.equal(input.configuration,'releaseRuntimeClasspath');assert.ok(input.components.length); - const components=[],nativeFiles=[],projectSources=[]; + const components=[],nativeFiles=[]; for(const value of input.components){ - if(value.projectBuildFile){projectSources.push(value.projectBuildFile);continue;} + if(value.projectBuildFile){ + const pkg=npmNoticeInputs(root,[value.projectBuildFile])[0];assert.ok(pkg,'Native Gradle project must have its own installed package.'); + const prefix='node_modules/'+relative(realpathSync(join(root,'node_modules')),realpathSync(dirname(value.projectBuildFile))).split(sep).join('/');assert.ok(prefix===pkg.packagePath||prefix.startsWith(pkg.packagePath+'/')); + const scope=prefix===pkg.packagePath?'':prefix.slice(pkg.packagePath.length+1),scan=nativeSourceNotices(root,dirname(value.projectBuildFile),prefix,{excluded:value.projectBuildDirectory?[value.projectBuildDirectory]:[]}); + const scoped=key=>scope?scope+'/'+key:key; + scan.vendors=scan.vendors.map(scoped);scan.vendorNotices=Object.fromEntries(Object.entries(scan.vendorNotices).map(([key,sources])=>[scoped(key),sources])); + components.push(...unionNoticeInputs([pkg],[{...pkg,...scan}]));continue; + } assert.ok(value.group&&value.name&&value.version&&value.artifacts.length);const notices=[],vendors=[]; for(const artifact of value.artifacts){ const bytes=readFileSync(artifact.file);assert.equal(digest(bytes),artifact.sha256,'Resolved artifact changed.'); @@ -64,9 +61,6 @@ export function gradleNoticeInputs(root,inputFile){ } components.push(nativeNoticeInput({id:`maven:${value.group}:${value.name}@${value.version}`,name:value.group+':'+value.name,version:value.version,ecosystem:'maven',notices,vendors})); } - // Autolinked Gradle projects are actual native inputs even if their JS entry - // did not occur in Metro. Resolve their own installed package, never :app. - if(projectSources.length)components.push(...npmNoticeInputs(root,projectSources)); return {components,nativeFiles,unresolved:[],inputHashes:{gradle:digest(readFileSync(inputFile))}}; } export function unionNoticeInputs(...groups){ @@ -76,7 +70,9 @@ export function unionNoticeInputs(...groups){ if(!previous){merged.set(item.id,item);continue;} const notices=new Map(previous.notices.map(value=>[value.source,value])); for(const value of item.notices){const before=notices.get(value.source);assert.ok(!before||before.sha256===value.sha256,'One source has conflicting notice bytes.');notices.set(value.source,value);} - merged.set(item.id,{...previous,vendors:[...new Set([...previous.vendors,...item.vendors])].sort(),notices:[...notices.values()].sort((a,b)=>a.source.localeCompare(b.source))}); + const nativeInputs={...previous.nativeInputs};for(const [key,value] of Object.entries(item.nativeInputs??{})){assert.ok(!nativeInputs[key]||JSON.stringify(nativeInputs[key])===JSON.stringify(value),'Native source input changed within one capture.');nativeInputs[key]=value;} + const vendorNotices={...previous.vendorNotices};for(const [key,value] of Object.entries(item.vendorNotices??{}))vendorNotices[key]=[...new Set([...(vendorNotices[key]??[]),...value])].sort(); + merged.set(item.id,{...previous,nativeInputs,vendorNotices,vendors:[...new Set([...previous.vendors,...item.vendors])].sort(),notices:[...notices.values()].sort((a,b)=>a.source.localeCompare(b.source))}); } return [...merged.values()].sort((a,b)=>a.id.localeCompare(b.id)); } diff --git a/scripts/native-notice-inputs.test.mjs b/scripts/native-notice-inputs.test.mjs index baf84e80..e329debc 100644 --- a/scripts/native-notice-inputs.test.mjs +++ b/scripts/native-notice-inputs.test.mjs @@ -6,9 +6,10 @@ import {join} from 'node:path'; import {createRequire} from 'node:module'; import test from 'node:test'; import {podNoticeInputs,gradleNoticeInputs,unionNoticeInputs,inspectNoticeArchive} from './native-notice-inputs.mjs'; -import {digest,resolveNoticeCoverage} from './app-notices.mjs'; +import {assertPackagedNativeCoverage,assertSourceNoticePayload,digest,npmNoticeInputs,resolveNoticeCoverage} from './app-notices.mjs'; const require=createRequire(import.meta.url),plist=require('@expo/plist'); const mit=readFileSync(new URL('../node_modules/react-native/LICENSE',import.meta.url),'utf8'); +const approval=(item,platform='ios')=>({schema:1,components:[{platform,component:item.id,reviewedVendors:item.vendors,nativeInputs:item.nativeInputs??{},notices:item.notices.map(({source,sha256})=>({source,sha256})),upstream:'https://example.invalid/pinned/native',sectionSHA256:digest([...item.notices].sort((a,b)=>a.source.localeCompare(b.source)).map(value=>value.body).join('\n\n'))}]}); function fixture(run){ const root=mkdtempSync(join(tmpdir(),'puptime-native-notice-')); const file=(path,body)=>{mkdirSync(join(root,path,'..'),{recursive:true});writeFileSync(join(root,path),typeof body==='string'||Buffer.isBuffer(body)?body:JSON.stringify(body));return join(root,path);}; @@ -25,7 +26,7 @@ test('resolved CocoaPods subspecs and actual generated plist text determine cove assert.ok(input.components.find(value=>value.name==='QA').notices.some(value=>value.body==='A distinct vendored attribution\n')); assert.deepEqual(input.components.find(value=>value.name==='QA').vendors,['Dependency.xcframework']); assert.equal(input.components.find(value=>value.name==='Missing').notices.length,0); - assert.equal(resolveNoticeCoverage(input.components,{schema:1,components:{}}).status,'INCOMPLETE'); + assert.equal(resolveNoticeCoverage(input.components,{schema:1,components:[]}).status,'INCOMPLETE'); })); test('actual AAR and nested JAR LICENSE plus NOTICE text are retained, with artifact digest and native-library provenance',()=>fixture(({root,file})=>{ @@ -57,3 +58,69 @@ test('union of JS and autolinked native inputs cannot drop a NOTICE or accept co assert.equal(unionNoticeInputs([base],[additional])[0].notices.length,2); assert.throws(()=>unionNoticeInputs([base],[{...base,notices:[{...base.notices[0],sha256:'0'.repeat(64)}]}])); }); + +test('autolinked Gradle inputs include nested native notices/vendors and bind source fingerprints without parent inheritance',()=>fixture(({root,file})=>{ + file('package-lock.json',{packages:{'node_modules/qa':{version:'1'}}});file('node_modules/qa/package.json',{name:'qa',version:'1'});file('node_modules/qa/LICENSE',mit); + const build=file('node_modules/qa/android/build.gradle','// synthetic project\n'); + file('node_modules/qa/android/src/main/cpp/NOTICE','Native subtree attribution\n');file('node_modules/qa/android/src/main/cpp/main.cpp','int original=1;'); + file('node_modules/qa/android/third_party/child/child.cpp','int child=1;'); + const input=file('gradle.json',{schema:1,configuration:'releaseRuntimeClasspath',components:[{projectBuildFile:build,projectBuildDirectory:join(root,'node_modules/qa/android/build')}]}); + const initial=gradleNoticeInputs(root,input).components[0];assert.ok(initial.notices.some(value=>value.body==='Native subtree attribution\n')); + assert.ok(initial.vendors.includes('android/third_party/child')); + const missing=approval(initial);missing.components[0].reviewedVendors=[]; + assert.equal(resolveNoticeCoverage([initial],missing).unresolved[0].reason,'unreviewed-vendored-source'); + assert.equal(resolveNoticeCoverage([initial],approval(initial)).unresolved[0].reason,'missing-vendored-notice'); + file('node_modules/qa/android/third_party/child/LICENSE',mit);file('node_modules/qa/android/third_party/child/NOTICE','Child attribution\n'); + const complete=gradleNoticeInputs(root,input).components[0],approved=approval(complete); + assert.equal(resolveNoticeCoverage([complete],approved).status,'READY'); + file('node_modules/qa/android/build/generated/Unrelated.cpp','generated output');assert.deepEqual(gradleNoticeInputs(root,input).components[0].nativeInputs,complete.nativeInputs); + file('node_modules/qa/android/src/main/cpp/main.cpp','int changed=2;'); + assert.equal(resolveNoticeCoverage(gradleNoticeInputs(root,input).components,approved).unresolved[0].reason,'native-source-digest-mismatch'); +})); + +test('one stable union independently qualifies the same npm ID with different Android native inputs',()=>fixture(({root,file})=>{ + file('package-lock.json',{packages:{'node_modules/qa':{version:'1'}}});file('node_modules/qa/package.json',{name:'qa',version:'1'});file('node_modules/qa/LICENSE',mit); + const js=file('node_modules/qa/index.js','module.exports=1;'),build=file('node_modules/qa/android/build.gradle','// native build\n');file('node_modules/qa/android/src/NOTICE','Android native notice\n'); + const input=file('gradle.json',{schema:1,configuration:'releaseRuntimeClasspath',components:[{projectBuildFile:build}]}); + const ios=npmNoticeInputs(root,[js])[0],android=gradleNoticeInputs(root,input).components[0];assert.equal(ios.id,android.id);assert.ok(android.notices.length>ios.notices.length); + const ledger={schema:1,components:[...approval(ios,'ios').components,...approval(android,'android').components]}; + const iosCoverage=resolveNoticeCoverage([ios],ledger,'ios'),androidCoverage=resolveNoticeCoverage([android],ledger,'android');assert.equal(iosCoverage.status,'READY');assert.equal(androidCoverage.status,'READY'); + assert.notEqual(iosCoverage.sections[0].id,androidCoverage.sections[0].id); + const font={id:'font',title:'Font',body:mit},fonts={notices:{font:digest(mit)},platforms:{font:['ios','android','web']}},payload=Buffer.from(JSON.stringify([font,...iosCoverage.sections,...androidCoverage.sections])); + assertSourceNoticePayload(payload,iosCoverage,ledger,fonts);assertSourceNoticePayload(payload,androidCoverage,ledger,fonts); + assert.equal(resolveNoticeCoverage([android],approval(ios,'ios'),'android').status,'INCOMPLETE'); + assert.equal(resolveNoticeCoverage([android],approval(ios,'android'),'android').status,'INCOMPLETE'); + assert.throws(()=>resolveNoticeCoverage([ios],{schema:1,components:[...ledger.components,ledger.components[1]]},'ios')); + assert.throws(()=>assertSourceNoticePayload(Buffer.from(JSON.stringify([font,...iosCoverage.sections])),iosCoverage,ledger,fonts)); + const hidden=JSON.parse(payload);delete hidden[2].androidOnly;hidden[2].iosOnly=true;assert.throws(()=>assertSourceNoticePayload(Buffer.from(JSON.stringify(hidden)),androidCoverage,ledger,fonts)); +})); + +test('Pods use the same source-only vendor boundary rule and reject raw invalid UTF-8 notices/acknowledgements',()=>fixture(({root,file})=>{ + file('ios/Podfile.lock','PODS:\n - QA (1)\n'); + const ack=plist.default.build({PreferenceSpecifiers:[{Title:'QA',FooterText:mit}]}),ackPath='ios/Pods/Target Support Files/Pods-Puptime/Pods-Puptime-acknowledgements.plist';file(ackPath,ack); + file('ios/Pods/QA/LICENSE',mit);file('ios/Pods/QA/vendor/child/file.cpp','int child=1;'); + const initial=podNoticeInputs(root).components[0];assert.ok(initial.vendors.includes('vendor/child')); + const noReview=approval(initial);noReview.components[0].reviewedVendors=[]; + assert.equal(resolveNoticeCoverage([initial],noReview).unresolved[0].reason,'unreviewed-vendored-source'); + assert.equal(resolveNoticeCoverage([initial],approval(initial)).unresolved[0].reason,'missing-vendored-notice'); + file('ios/Pods/QA/vendor/child/NOTICE','Actual child attribution\n');const complete=podNoticeInputs(root).components[0];assert.equal(resolveNoticeCoverage([complete],approval(complete)).status,'READY'); + file('ios/Pods/QA/LICENSE',Buffer.from([0xff]));assert.throws(()=>podNoticeInputs(root),'Invalid source bytes must not be repaired into U+FFFD.'); + file('ios/Pods/QA/LICENSE',mit);const bytes=Buffer.from(ack),at=bytes.indexOf('Copyright');assert.ok(at>0);file(ackPath,Buffer.concat([bytes.subarray(0,at),Buffer.from([0xff]),bytes.subarray(at+1)]));assert.throws(()=>podNoticeInputs(root)); +})); + +test('archive inventory includes extensionless main/appex Mach-O and unknown executables require coverage',()=>fixture(({root})=>{ + const path=join(root,'fixture.ipa'); + execFileSync('python3',['-c',`import zipfile,struct,sys\nfat=struct.pack('>II',0xcafebabe,2)+struct.pack('>IIIII',12,0,48,8,0)+struct.pack('>IIIII',7,0,56,8,0)+b'\\xcf\\xfa\\xed\\xfeabcd'*2\nwith zipfile.ZipFile(sys.argv[1],'w') as z:\n for name in ['Payload/Puptime.app/Puptime','Payload/Puptime.app/PlugIns/Unknown.appex/Unknown','Payload/Puptime.app/Frameworks/QA.framework/QA']:z.writestr(name,b'\\xcf\\xfa\\xed\\xfecontrolled')\n z.writestr('Payload/Puptime.app/fat-native',fat);z.writestr('Payload/Puptime.app/Qa.class',bytes.fromhex('cafebabe000000340001'));z.writestr('Payload/Puptime.app/text','plain text')`,path]); + const result=inspectNoticeArchive(path);assert.equal(result.nativeFiles.length,4);assert.ok(!result.nativeFiles.some(value=>value.path.endsWith('.class'))); + const component='native:qa@1',framework=result.nativeFiles.find(value=>value.path.includes('.framework/')); + const ledger={packagedNative:{ios:{[framework.path]:{components:[component],review:'https://example.invalid/pinned/native'}}}}; + assert.throws(()=>assertPackagedNativeCoverage('ios',result.nativeFiles,[],{components:[{component}]},ledger),'Main and extension binaries cannot silently disappear from the inventory.'); +})); + +test('the exact raw JSON resource has its own 16 MiB bound and is not counted again as a generic 1 MiB notice',()=>fixture(({root})=>{ + const path=join(root,'fixture.apk'),payload=join(root,'payload.json'); + execFileSync('python3',['-c',`import json,zipfile,sys\nb=json.dumps([{'id':'large','title':'Large','body':'x'*(1024*1024+20)}]).encode();open(sys.argv[2],'wb').write(b)\nwith zipfile.ZipFile(sys.argv[1],'w') as z:z.writestr('assets/third_party_notices.json',b);z.writestr('META-INF/LICENSE','Actual small licence')`,path,payload]); + const result=inspectNoticeArchive(path,'assets/third_party_notices.json');assert.equal(result.payload.sha256,digest(readFileSync(payload)));assert.equal(result.notices.length,1); + execFileSync('python3',['-c',`import json,zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:z.writestr('assets/third_party_notices.json',json.dumps([{'body':'x'*(17*1024*1024)}]))`,path]); + assert.throws(()=>inspectNoticeArchive(path,'assets/third_party_notices.json')); +})); diff --git a/scripts/notice-archive.py b/scripts/notice-archive.py index 8bfe7c67..45c0ee67 100644 --- a/scripts/notice-archive.py +++ b/scripts/notice-archive.py @@ -4,6 +4,7 @@ import io import json import re +import struct import sys import zipfile from pathlib import Path, PurePosixPath @@ -13,6 +14,30 @@ MAX_NESTED = 128 * MAX_TEXT +def is_macho(archive, entry): + # CAFEBABE is shared with JVM class files. Check the bounded universal + # architecture table, not magic alone (Apple mach-o/fat.h; JVMS 4.1). + with archive.open(entry) as stream: + header = stream.read(8) + magic = header[:4] + if magic in [b"\xcf\xfa\xed\xfe", b"\xfe\xed\xfa\xcf", b"\xce\xfa\xed\xfe", b"\xfe\xed\xfa\xce"]: + return True + if magic not in [b"\xca\xfe\xba\xbe", b"\xbe\xba\xfe\xca", b"\xca\xfe\xba\xbf", b"\xbf\xba\xfe\xca"]: + return False + endian = ">" if magic[:2] == b"\xca\xfe" else "<" + count = struct.unpack(endian + "I", header[4:8])[0] if len(header) == 8 else 0 + wide = magic in [b"\xca\xfe\xba\xbf", b"\xbf\xba\xfe\xca"] + width = 32 if wide else 20 + if 0 < count <= 64 and 8 + count * width <= entry.file_size: + table = stream.read(count * width) + ranges = [struct.unpack_from(endian + ("QQ" if wide else "II"), table, i * width + 8) for i in range(count)] + if all(offset >= 8 + count * width and size >= 4 and offset + size <= entry.file_size for offset, size in ranges): + return True + if magic == b"\xca\xfe\xba\xbe" and entry.filename.endswith(".class") and len(header) == 8 and struct.unpack(">H", header[6:8])[0] >= 45: + return False + raise ValueError("Malformed or unsupported universal native input") + + def inspect_archive(path, payload_path=None, bundle_path=None): result = {"notices": [], "nativeFiles": [], "payload": None, "bundle": None} total = 0 @@ -43,7 +68,7 @@ def inspect(source, prefix="", depth=0): if not 0 < entry.file_size <= MAX_NESTED: raise ValueError("Empty or oversized application bundle") result["bundle"] = {"sha256": hashlib.sha256(archive.read(entry)).hexdigest(), "bytes": entry.file_size} - if any(re.fullmatch(r"(licen[sc]es?|notices?|copying|copyright|third[._-]?party[._-]?(licen[sc]es?|notices?))([._-].*)?", part, re.I) for part in PurePosixPath(name).parts): + if not (payload_path and not prefix and name == payload_path) and any(re.fullmatch(r"(licen[sc]es?|notices?|copying|copyright|third[._-]?party[._-]?(licen[sc]es?|notices?))([._-].*)?", part, re.I) for part in PurePosixPath(name).parts): if not 0 < entry.file_size <= MAX_TEXT: raise ValueError("Empty or oversized notice") total += entry.file_size @@ -54,17 +79,10 @@ def inspect(source, prefix="", depth=0): if not body.strip() or "\0" in body: raise ValueError("Unreadable notice") result["notices"].append({"source": logical, "body": body, "sha256": hashlib.sha256(raw).hexdigest()}) - if name.endswith(".so"): + if name.endswith(".so") or is_macho(archive, entry): if entry.file_size > MAX_NESTED: raise ValueError("Oversized native input") result["nativeFiles"].append({"path": logical, "sha256": hashlib.sha256(archive.read(entry)).hexdigest()}) - elif ".framework/" in name or name.endswith(".dylib"): - with archive.open(entry) as stream: - magic = stream.read(4) - if magic in [b"\xcf\xfa\xed\xfe", b"\xfe\xed\xfa\xcf", b"\xca\xfe\xba\xbe", b"\xbe\xba\xfe\xca"]: - if entry.file_size > MAX_NESTED: - raise ValueError("Oversized native input") - result["nativeFiles"].append({"path": logical, "sha256": hashlib.sha256(archive.read(entry)).hexdigest()}) if depth == 0 and name.endswith(".jar"): if entry.file_size > MAX_NESTED: raise ValueError("Oversized nested SDK archive") diff --git a/scripts/notices-runtime.init.gradle b/scripts/notices-runtime.init.gradle index bfd78614..80fcb3a1 100644 --- a/scripts/notices-runtime.init.gradle +++ b/scripts/notices-runtime.init.gradle @@ -32,7 +32,7 @@ gradle.projectsEvaluated { if (id instanceof ProjectComponentIdentifier && id.projectPath != ':app') { def project = gradle.rootProject.findProject(id.projectPath) if (project == null) throw new GradleException('Unresolved native project input') - return [projectBuildFile: project.buildFile.absolutePath] + return [projectBuildFile: project.buildFile.absolutePath, projectBuildDirectory: project.layout.buildDirectory.get().asFile.absolutePath] } if (id instanceof ProjectComponentIdentifier && id.projectPath == ':app') return null throw new GradleException('Unknown release component input') diff --git a/src/font-icon-notices.test.ts b/src/font-icon-notices.test.ts index 918bc5c7..a41f65b0 100644 --- a/src/font-icon-notices.test.ts +++ b/src/font-icon-notices.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import {createHash} from 'node:crypto'; import {readFileSync,readdirSync} from 'node:fs'; import test from 'node:test'; +import {assertSectionPlatforms} from '../scripts/app-notices.mjs'; const root=new URL('../',import.meta.url); const read=(path:string)=>readFileSync(new URL(path,root)); @@ -19,7 +20,7 @@ test('font bytes, glyphs, pinned dependencies and verbatim license copies requir for(const [path,expected] of Object.entries(provenance.files))assert.equal(hash(read(path)),expected,`${path}: review font/license provenance`); assert.equal(notices.length,Object.keys(provenance.notices).length); assert.equal(new Set(notices.map(section=>section.id)).size,notices.length); - for(const section of notices)assert.equal(hash(section.body),provenance.notices[section.id],`${section.id}: license text changed`); + for(const section of notices){assert.equal(hash(section.body),provenance.notices[section.id],`${section.id}: license text changed`);assertSectionPlatforms(section,provenance.platforms[section.id]);} const body=(id:string)=>notices.find(section=>section.id===id)!.body; assert.equal(body('red-hat'),read('assets/fonts/RedHat-OFL.txt').toString()); assert.equal(body('expo-vector-icons'),read('node_modules/@expo/vector-icons/LICENSE').toString()); From 2e2ebb1ab39c55336f310348e498c56d1695cad9 Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 04:53:48 +0800 Subject: [PATCH 10/37] Bind notice checks to clean source and configured CMake outputs --- docs/app-notices.md | 8 +++++++- scripts/app-notices.test.mjs | 7 +++++++ scripts/capture-app-notices.mjs | 1 + scripts/native-notice-inputs.mjs | 2 +- scripts/native-notice-inputs.test.mjs | 16 ++++++++++++++-- scripts/notices-runtime.init.gradle | 6 +++++- 6 files changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index 9b99e250..277a83a7 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -67,6 +67,8 @@ native inputs are complete, all vendors/text are reviewed, font pins match, the payload/ledger/package manifest/lock/app config/relevant source files equal Git HEAD in the canonical checkout root, and the view imports the same resource. A passing `seal.json` records that exact source/lock/native-input graph. +All tracked working-tree content must equal HEAD, including build configuration +outside the selected graph; ignored generated native/build outputs are permitted. Missing input metadata or a font-only placeholder cannot pass. A failed seal removes previous seal/final receipts. There is no generation or fallback mode. @@ -142,7 +144,11 @@ walks Pods and autolinked Gradle native subtrees, fingerprints source files, and finds source-only `vendor`/`third_party` children as well as binary vendors. Native roots are restricted to installed packages, Pods and identified local modules; arbitrary project data is not traversed. -Only the actual Gradle build-directory output and Git metadata are excluded. +Only the actual Gradle build-directory output, configured CMake staging path and +Git metadata are excluded. The metadata exporter reads AGP's CMake configuration; +its module-local `.cxx` default applies only when CMake is enabled. Canonical +excluded paths are recorded; there is no `.cxx`/vendor wildcard. See the pinned +[AGP CMake staging contract](https://developer.android.com/reference/tools/gradle-api/8.12/com/android/build/api/dsl/Cmake#buildStagingDirectory()). Vendor text must be present within its boundary, or explicitly mapped to a pinned actual supplemental notice using `vendorNoticeSources`; approving a vendor name alone cannot borrow the root licence. Missing source/text, diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs index 8ea726f8..d5363a8c 100644 --- a/scripts/app-notices.test.mjs +++ b/scripts/app-notices.test.mjs @@ -140,6 +140,7 @@ test('public review serialization includes only exact pinned public notice bodie test('actual verify CLI derives final components from map/native inputs and rejects missing assets, extras and dirty HEAD inputs',()=>fixture(({root,file})=>{ const runGit=(...args)=>execFileSync('git',['-c','core.hooksPath=/dev/null','-c','commit.gpgsign=false',...args],{cwd:root,stdio:['ignore','pipe','pipe']}); file('.gitignore','node_modules/\nbuild/\n');file('package.json',{name:'fixture',version:'1'}); + const buildConfig='module.exports={presets:["controlled-preset"]};\n';file('babel.config.cjs',buildConfig); const app={expo:{plugins:[['expo-asset',{assets:['./assets/notices/third_party_notices.json']}]]}};file('app.json',app); const lock={packages:{'node_modules/qa':{version:'1'},'node_modules/assets':{version:'1'},'node_modules/extra':{version:'1'}}};file('package-lock.json',lock); for(const name of ['qa','assets','extra']){file(`node_modules/${name}/package.json`,{name,version:'1'});file(`node_modules/${name}/LICENSE`,mit);} @@ -162,6 +163,12 @@ test('actual verify CLI derives final components from map/native inputs and reje const pack=(extra=false)=>execFileSync('python3',['-c',`import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:\n z.write(sys.argv[2],'assets/third_party_notices.json');z.write(sys.argv[3],'assets/index.android.bundle');z.writestr('lib/arm64-v8a/libqa.so',b'\\x7fELFcontrolled');z.writestr('assets/font.ttf','synthetic font bytes')\n if sys.argv[4]=='yes':z.writestr('assets/unknown-executable',b'\\xcf\\xfa\\xed\\xfecontrolled')`,apk,join(root,'assets/notices/third_party_notices.json'),bundle,extra?'yes':'no']); const verify=()=>spawnSync(process.execPath,[fileURLToPath(new URL('./capture-app-notices.mjs',import.meta.url)),'verify','android','--gradle-input',gradle,'--archive',apk,'--bundle',bundle,'--source-map',map],{cwd:root,encoding:'utf8',timeout:30000}); pack();assert.equal(verify().status,0); + file('babel.config.cjs','module.exports={presets:["changed-preset"]};\n'); + assert.deepEqual(captureResolvedInputs(root,'android',paths,gradle).sources,capture.sources,'The selected source paths/bytes remain identical.'); + assert.throws(()=>createNoticeSeal(root,'android',capture),'Unselected dirty tracked build configuration cannot be labeled HEAD.');assert.equal(verify().status,1); + runGit('add','babel.config.cjs');assert.throws(()=>createNoticeSeal(root,'android',capture),'Staged build configuration drift must fail too.');assert.equal(verify().status,1); + file('babel.config.cjs',buildConfig);runGit('restore','--staged','--','babel.config.cjs'); + assert.equal(verify().status,0,'Ignored generated build outputs do not make the tracked worktree dirty.'); const receipt=JSON.parse(readFileSync(join(root,'build/notices/android/final-apk.json')));assert.equal(receipt.inventory.independentFinalComponentInventory,false); assert.deepEqual(receipt.inventory.resolvedBuildOnly,[{component:'maven:qa:static@1',basis:'reinspected-gradle-artifacts-and-source'}]); const missing={...sourceMap,sources:sourceMap.sources.filter((_,i)=>i!==2),sourcesContent:sourceMap.sourcesContent.filter((_,i)=>i!==2)};file('build/actual.map',missing); diff --git a/scripts/capture-app-notices.mjs b/scripts/capture-app-notices.mjs index 584f3136..6415dda3 100644 --- a/scripts/capture-app-notices.mjs +++ b/scripts/capture-app-notices.mjs @@ -55,6 +55,7 @@ async function metroPaths(root,platform,privateRoot){ export function assertReviewedSource(root,capture){ assert.equal(realpathSync(root),realpathSync(git(root,'rev-parse','--show-toplevel').toString().trim()),'Use the canonical checkout root.'); + git(root,'diff','--quiet','HEAD','--'); const {ledger,fonts,sources,coverage}=capture; assertCapturedSources(root,sources,path=>committed(root,path)); for(const path of ['package.json','package-lock.json','app.json',ledgerPath,fontPath,payloadPath])strictSource(root,path); diff --git a/scripts/native-notice-inputs.mjs b/scripts/native-notice-inputs.mjs index c483a2f2..b436eaa6 100644 --- a/scripts/native-notice-inputs.mjs +++ b/scripts/native-notice-inputs.mjs @@ -46,7 +46,7 @@ export function gradleNoticeInputs(root,inputFile){ if(value.projectBuildFile){ const pkg=npmNoticeInputs(root,[value.projectBuildFile])[0];assert.ok(pkg,'Native Gradle project must have its own installed package.'); const prefix='node_modules/'+relative(realpathSync(join(root,'node_modules')),realpathSync(dirname(value.projectBuildFile))).split(sep).join('/');assert.ok(prefix===pkg.packagePath||prefix.startsWith(pkg.packagePath+'/')); - const scope=prefix===pkg.packagePath?'':prefix.slice(pkg.packagePath.length+1),scan=nativeSourceNotices(root,dirname(value.projectBuildFile),prefix,{excluded:value.projectBuildDirectory?[value.projectBuildDirectory]:[]}); + const scope=prefix===pkg.packagePath?'':prefix.slice(pkg.packagePath.length+1),scan=nativeSourceNotices(root,dirname(value.projectBuildFile),prefix,{excluded:[value.projectBuildDirectory,value.projectCmakeStagingDirectory].filter(Boolean)}); const scoped=key=>scope?scope+'/'+key:key; scan.vendors=scan.vendors.map(scoped);scan.vendorNotices=Object.fromEntries(Object.entries(scan.vendorNotices).map(([key,sources])=>[scoped(key),sources])); components.push(...unionNoticeInputs([pkg],[{...pkg,...scan}]));continue; diff --git a/scripts/native-notice-inputs.test.mjs b/scripts/native-notice-inputs.test.mjs index e329debc..9b542617 100644 --- a/scripts/native-notice-inputs.test.mjs +++ b/scripts/native-notice-inputs.test.mjs @@ -63,8 +63,9 @@ test('autolinked Gradle inputs include nested native notices/vendors and bind so file('package-lock.json',{packages:{'node_modules/qa':{version:'1'}}});file('node_modules/qa/package.json',{name:'qa',version:'1'});file('node_modules/qa/LICENSE',mit); const build=file('node_modules/qa/android/build.gradle','// synthetic project\n'); file('node_modules/qa/android/src/main/cpp/NOTICE','Native subtree attribution\n');file('node_modules/qa/android/src/main/cpp/main.cpp','int original=1;'); + file('node_modules/qa/android/.cxx-adjacent/source.cpp','int adjacent=1;'); file('node_modules/qa/android/third_party/child/child.cpp','int child=1;'); - const input=file('gradle.json',{schema:1,configuration:'releaseRuntimeClasspath',components:[{projectBuildFile:build,projectBuildDirectory:join(root,'node_modules/qa/android/build')}]}); + const input=file('gradle.json',{schema:1,configuration:'releaseRuntimeClasspath',components:[{projectBuildFile:build,projectBuildDirectory:join(root,'node_modules/qa/android/build'),projectCmakeStagingDirectory:join(root,'node_modules/qa/android/.cxx')}]}); const initial=gradleNoticeInputs(root,input).components[0];assert.ok(initial.notices.some(value=>value.body==='Native subtree attribution\n')); assert.ok(initial.vendors.includes('android/third_party/child')); const missing=approval(initial);missing.components[0].reviewedVendors=[]; @@ -73,11 +74,22 @@ test('autolinked Gradle inputs include nested native notices/vendors and bind so file('node_modules/qa/android/third_party/child/LICENSE',mit);file('node_modules/qa/android/third_party/child/NOTICE','Child attribution\n'); const complete=gradleNoticeInputs(root,input).components[0],approved=approval(complete); assert.equal(resolveNoticeCoverage([complete],approved).status,'READY'); - file('node_modules/qa/android/build/generated/Unrelated.cpp','generated output');assert.deepEqual(gradleNoticeInputs(root,input).components[0].nativeInputs,complete.nativeInputs); + file('node_modules/qa/android/build/generated/Unrelated.cpp','generated output');file('node_modules/qa/android/.cxx/Release/vendor/generated/no-license.cpp','generated CMake output'); + assert.deepEqual(gradleNoticeInputs(root,input).components[0].nativeInputs,complete.nativeInputs);assert.equal(resolveNoticeCoverage(gradleNoticeInputs(root,input).components,approved).status,'READY'); + assert.deepEqual(complete.nativeInputs['node_modules/qa/android'].excludedBuildPaths,['.cxx','build']); + file('node_modules/qa/android/.cxx-adjacent/source.cpp','int adjacent=2;');assert.equal(resolveNoticeCoverage(gradleNoticeInputs(root,input).components,approved).unresolved[0].reason,'native-source-digest-mismatch');file('node_modules/qa/android/.cxx-adjacent/source.cpp','int adjacent=1;'); file('node_modules/qa/android/src/main/cpp/main.cpp','int changed=2;'); assert.equal(resolveNoticeCoverage(gradleNoticeInputs(root,input).components,approved).unresolved[0].reason,'native-source-digest-mismatch'); })); +test('a custom AGP staging path does not exclude an unrelated directory merely named .cxx',()=>fixture(({root,file})=>{ + file('package-lock.json',{packages:{'node_modules/qa':{version:'1'}}});file('node_modules/qa/package.json',{name:'qa',version:'1'});file('node_modules/qa/LICENSE',mit); + const build=file('node_modules/qa/android/build.gradle','// configured CMake staging\n');file('node_modules/qa/android/.cxx/source.cpp','int source=1;'); + const input=file('gradle.json',{schema:1,configuration:'releaseRuntimeClasspath',components:[{projectBuildFile:build,projectCmakeStagingDirectory:join(root,'node_modules/qa/android/generated-cmake')}]}); + const before=gradleNoticeInputs(root,input).components[0];file('node_modules/qa/android/generated-cmake/vendor/generated.cpp','generated');assert.deepEqual(gradleNoticeInputs(root,input).components[0].nativeInputs,before.nativeInputs); + file('node_modules/qa/android/.cxx/source.cpp','int source=2;');assert.notDeepEqual(gradleNoticeInputs(root,input).components[0].nativeInputs,before.nativeInputs); +})); + test('one stable union independently qualifies the same npm ID with different Android native inputs',()=>fixture(({root,file})=>{ file('package-lock.json',{packages:{'node_modules/qa':{version:'1'}}});file('node_modules/qa/package.json',{name:'qa',version:'1'});file('node_modules/qa/LICENSE',mit); const js=file('node_modules/qa/index.js','module.exports=1;'),build=file('node_modules/qa/android/build.gradle','// native build\n');file('node_modules/qa/android/src/NOTICE','Android native notice\n'); diff --git a/scripts/notices-runtime.init.gradle b/scripts/notices-runtime.init.gradle index 80fcb3a1..c741e513 100644 --- a/scripts/notices-runtime.init.gradle +++ b/scripts/notices-runtime.init.gradle @@ -32,7 +32,11 @@ gradle.projectsEvaluated { if (id instanceof ProjectComponentIdentifier && id.projectPath != ':app') { def project = gradle.rootProject.findProject(id.projectPath) if (project == null) throw new GradleException('Unresolved native project input') - return [projectBuildFile: project.buildFile.absolutePath, projectBuildDirectory: project.layout.buildDirectory.get().asFile.absolutePath] + def cmake = project.extensions.findByName('android')?.externalNativeBuild?.cmake + // AGP's configured directory, or its documented module-local + // default only when this project actually enables CMake. + def staging = cmake?.path != null ? project.file(cmake.buildStagingDirectory ?: new File(project.projectDir, '.cxx')).canonicalPath : null + return [projectBuildFile: project.buildFile.absolutePath, projectBuildDirectory: project.layout.buildDirectory.get().asFile.canonicalPath, projectCmakeStagingDirectory: staging] } if (id instanceof ProjectComponentIdentifier && id.projectPath == ':app') return null throw new GradleException('Unknown release component input') From 066950798406db46639c56a05ad3fbf22b468d4e Mon Sep 17 00:00:00 2001 From: Patrick Chin Date: Sun, 15 Nov 2026 04:55:58 +0800 Subject: [PATCH 11/37] Verify actual offline Android connectivity before signing (#219) Co-authored-by: Patrick Chin <1392122+patrickchin@users.noreply.github.com> --- .github/workflows/android-signing-proof.yml | 1 + scripts/verify-android-signing-upgrade.mjs | 46 +++++++++++++++++---- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/.github/workflows/android-signing-proof.yml b/.github/workflows/android-signing-proof.yml index 78fdd11b..bfc61d6f 100644 --- a/.github/workflows/android-signing-proof.yml +++ b/.github/workflows/android-signing-proof.yml @@ -118,6 +118,7 @@ jobs: adb -s emulator-5588 shell svc wifi disable adb -s emulator-5588 shell svc data disable adb -s emulator-5588 shell cmd connectivity airplane-mode enable + node scripts/verify-android-signing-upgrade.mjs --emulator-offline - name: Install the established Maestro version env: MAESTRO_VERSION: '2.11.0' diff --git a/scripts/verify-android-signing-upgrade.mjs b/scripts/verify-android-signing-upgrade.mjs index e6f602f0..ae8c04b1 100644 --- a/scripts/verify-android-signing-upgrade.mjs +++ b/scripts/verify-android-signing-upgrade.mjs @@ -73,6 +73,7 @@ function widgetBinding(text){ } function selfCheck(){ emulatorSelfCheck(); + offlineSelfCheck(); assertWidgetSource(); validateDriver('d'.repeat(40),'d'.repeat(40));assert.throws(()=>validateDriver('d'.repeat(40),'e'.repeat(40)));assert.throws(()=>validateDriver('short','short')); const change=path=>`:100644 100644 ${'a'.repeat(40)} ${'b'.repeat(40)} M\0${path}\0`; @@ -94,6 +95,35 @@ function selfCheck(){ const dump='\nWidgets:\n [0] id=2\n host=HostId{user:0, app:10001, hostId:1024, pkg:launcher}\n provider=ProviderId{user:0, app:10002, cmp:ComponentInfo{'+app+'/'+app+'.widget.PuptimeQuickLog}}\n views=random\nHosts:\n';assert.equal(widgetBinding(dump).id,'2');assert.throws(()=>widgetBinding(dump.replace(app+'/','other/')));assert.throws(()=>widgetBinding(dump.replace(' host=',' invalid='))); console.log('PASS signing QA source self-checks; no network, keys, signing or device operations.'); } +function offlineSnapshot({airplane,wifiPreference,wifiStatus,connectivity,mobilePreference}){ + const text=connectivity.replaceAll('\r',''),defaults=text.match(/^[ \t]*Active default network: [^\n]*$/gm)??[],starts=text.match(/^[ \t]*Current Networks:[ \t]*$/gm)??[],ends=text.match(/^[ \t]*Status for known UIDs:[ \t]*$/gm)??[],section=text.match(/^[ \t]*Current Networks:[ \t]*\n([\s\S]*?)^[ \t]*Status for known UIDs:[ \t]*$/m); + return {airplaneEnabled:airplane.trim()==='1',wifiPreferenceDisabled:wifiPreference.trim()==='0',wifiDisabled:wifiStatus.replaceAll('\r','').split('\n')[0].trim()==='Wifi is disabled',noDefaultNetwork:defaults.length===1&&defaults[0].trim()==='Active default network: none',noRegisteredNetworks:starts.length===1&&ends.length===1&&!!section&§ion[1].trim()==='',mobileDataPreference:({'0':'disabled','1':'enabled',null:'unset'})[mobilePreference.trim()]??'other'}; +} +const offlineReady=value=>['airplaneEnabled','wifiPreferenceDisabled','wifiDisabled','noDefaultNetwork','noRegisteredNetworks'].every(key=>value[key]===true); +function readOffline(adb){ + const read=(...args)=>{const result=adb(['shell',...args],5000);assert.equal(result.status,0,'Offline readback command failed.');return result.stdout;}; + return offlineSnapshot({airplane:read('settings','get','global','airplane_mode_on'),wifiPreference:read('settings','get','global','wifi_on'),wifiStatus:read('cmd','wifi','status'),connectivity:read('dumpsys','connectivity'),mobilePreference:read('settings','get','global','mobile_data')}); +} +function emulatorOffline(io,state){ + state.offline='pending';io.save(state);const until=io.now()+30000; + do{ + assert.ok(ownedProcess(io.inspect(),io.start),'Offline setup requires the same live owned emulator.');Object.assign(state,readOffline(io.adb)); + if(offlineReady(state)){state.offline='verified';io.save(state);return;}io.save(state);io.pause(); + }while(io.now()({alive:true,start:'42',executable:true,arguments:true,home:true,uid:true}),save:()=>{},now:()=>time,pause:()=>time+=1000,adb:args=>{calls.push(args.join(' '));return {status:0,stdout:responses[args.slice(1).join(' ')],stderr:''};}}; + assert.deepEqual(readOffline(io.adb),good);io.adb=args=>({status:0,stdout:args[1]==='dumpsys'&&reads++===0?empty.replace('network: none','network: 100'):responses[args.slice(1).join(' ')],stderr:''});emulatorOffline(io,state);assert.equal(state.offline,'verified');assert.equal(time,1000); + time=0;io.adb=args=>({status:0,stdout:args[1]==='dumpsys'?empty.replace('Current Networks:\n','Current Networks:\n nondefault active network\n'):responses[args.slice(1).join(' ')],stderr:''});assert.throws(()=>emulatorOffline(io,state));assert.equal(state.offline,'timeout');assert.equal(time,30000); + for(const status of [1,null])assert.throws(()=>readOffline(()=>({status,stdout:'',stderr:'private failure'}))); + io.inspect=()=>null;assert.throws(()=>emulatorOffline(io,state));assert.ok(calls.every(value=>!value.includes(' root')&&!value.includes(' put '))); + console.log('PASS actual offline production-parser/command/readiness stubs; saved mobile-data preference never substitutes for absent connectivity.'); +} + function emulatorSelfCheck(){ const base={alive:true,start:'42',executable:true,arguments:true,home:true,uid:true}; const stub=()=>{let now=0,alive=true;const calls=[],state={},io={name:'Puptime_Signing_QA_123',start:'42',inspect:()=>({...base,alive}),save:()=>{},now:()=>now,pause:()=>now+=1000,signal:signal=>{calls.push(signal);alive=false;},adb:args=>{calls.push(args.join(' '));return {status:0,stdout:args[0]==='root'?'restarting adbd as root\n':args[0]==='emu'?'Puptime_Signing_QA_123\nOK\n':args[1]==='id'?'0\n':'1\n',stderr:''};}};return {io,state,calls,exit:()=>alive=false};}; @@ -130,9 +160,9 @@ function emulatorSelfCheck(){ // of success. AOSP documents adbd's restart: docs/dev/root.md in packages/modules/adb. function processStart(stat){const fields=stat.slice(stat.lastIndexOf(')')+2).trim().split(/\s+/);assert.match(fields[19]??'',/^\d+$/);return {start:fields[19],alive:!['Z','X'].includes(fields[0])};} function safeHostState(value){ - const allowed={liveness:['not-started','owned','identity-mismatch','exited'],boot:['not-started','pending','complete'],rootRequest:['not-issued','acknowledged','closed-transport','refused'],rootReadback:['not-attempted','pending','verified','timeout','identity-mismatch'],cleanup:['not-attempted','already-exited','console-exit','term-exit','kill-exit','ownership-refused','still-alive'],setup:['failed']},safe={}; + const allowed={liveness:['not-started','owned','identity-mismatch','exited'],boot:['not-started','pending','complete'],rootRequest:['not-issued','acknowledged','closed-transport','refused'],rootReadback:['not-attempted','pending','verified','timeout','identity-mismatch'],cleanup:['not-attempted','already-exited','console-exit','term-exit','kill-exit','ownership-refused','still-alive'],setup:['failed'],offline:['pending','verified','timeout'],mobileDataPreference:['enabled','disabled','unset','other']},safe={}; for(const [key,values] of Object.entries(allowed))if(key in value){assert.ok(values.includes(value[key]));safe[key]=value[key];} - if('privateMaterialRemoved' in value){assert.equal(typeof value.privateMaterialRemoved,'boolean');safe.privateMaterialRemoved=value.privateMaterialRemoved;}return safe; + for(const key of ['privateMaterialRemoved','airplaneEnabled','wifiPreferenceDisabled','wifiDisabled','noDefaultNetwork','noRegisteredNetworks','offlinePreKey'])if(key in value){assert.equal(typeof value[key],'boolean');safe[key]=value[key];}return safe; } function ownedProcess(value,start){return !!value?.alive&&value.start===start&&value.executable&&value.arguments&&value.home&&value.uid;} function processIdentity(files,expected){ @@ -195,11 +225,11 @@ function emulatorHost(mode){ return processIdentity({stat:readFileSync(join(proc,'stat'),'utf8'),exe:readlinkSync(join(proc,'exe')),cmdline:readFileSync(join(proc,'cmdline'),'utf8'),environ:readFileSync(join(proc,'environ'),'utf8'),status:readFileSync(join(proc,'status'),'utf8'),afterStat:readFileSync(join(proc,'stat'),'utf8')},{executables,arguments:['-avd',name,'-port','5588','-no-window','-no-audio','-no-snapshot','-gpu','swiftshader_indirect'],home:join(folder,'avd'),uid:String(process.getuid())}); }catch(error){if(error.code==='ENOENT'||error.code==='ESRCH')return null;throw error;} }; - const io={name,start:owner?.start,inspect,save,now:Date.now,pause:()=>Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,1000),signal:signal=>{try{process.kill(pid,signal);}catch(error){if(error.code!=='ESRCH')throw error;}},adb:(args,timeout=3000)=>{const value=spawnSync(join(sdk,'platform-tools/adb'),['-s','emulator-5588',...args],{encoding:'utf8',timeout,maxBuffer:65536,stdio:['ignore','pipe','pipe']});return {status:value.status,stdout:value.stdout??'',stderr:value.stderr??''};}}; + const io={name,start:owner?.start,inspect,save,now:Date.now,pause:()=>Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,1000),signal:signal=>{try{process.kill(pid,signal);}catch(error){if(error.code!=='ESRCH')throw error;}},adb:(args,timeout=3000)=>{const value=spawnSync(join(sdk,'platform-tools/adb'),['-s','emulator-5588',...args],{encoding:'utf8',timeout,maxBuffer:256*1024,stdio:['ignore','pipe','pipe']});return {status:value.status,stdout:value.stdout??'',stderr:value.stderr??''};}}; if(mode==='--emulator-record'){ assert.equal(owner,null);assert.ok(pid);const value=processStart(readFileSync('/proc/'+pid+'/stat','utf8'));assert.ok(value.alive);owner={pid,start:value.start};writeFileSync(ownerFile,JSON.stringify(owner),{flag:'wx',mode:0o600});io.start=value.start;recordOwnedEmulator(io,state); - }else if(mode==='--emulator-root'){ - assert.ok(owner);try{emulatorRoot(io,state);}catch(error){state.setup='failed';save(state);throw error;} + }else if(mode==='--emulator-root'||mode==='--emulator-offline'){ + assert.ok(owner);try{if(mode==='--emulator-root')emulatorRoot(io,state);else emulatorOffline(io,state);}catch(error){state.setup='failed';save(state);throw error;} }else{ let stopped=false; try{stopped=stopEmulator(io,state);}finally{ @@ -213,7 +243,7 @@ function emulatorHost(mode){ }catch(error){if(existsSync(folder))writeFileSync(join(folder,'emulator-host-failure.txt'),String(error.stack),{mode:0o600});throw error;} } -const [mode]=process.argv.slice(2);if(['--emulator-record','--emulator-root','--emulator-cleanup'].includes(mode)){try{emulatorHost(mode);process.exit(0);}catch{console.error('FAIL host-only '+mode.slice(2)+'; raw process/error evidence remains private.');process.exit(1);}}if(mode==='--self-check'){selfCheck();process.exit(0);} +const [mode]=process.argv.slice(2);if(['--emulator-record','--emulator-root','--emulator-offline','--emulator-cleanup'].includes(mode)){try{emulatorHost(mode);process.exit(0);}catch{console.error('FAIL host-only '+mode.slice(2)+'; raw process/error evidence remains private.');process.exit(1);}}if(mode==='--self-check'){selfCheck();process.exit(0);} assert.ok(['--preflight','--exercise'].includes(mode));assert.equal(process.env.GITHUB_ACTIONS,'true');assert.equal(process.env.RUNNER_OS,'Linux');process.umask(0o077); const root=resolve(process.env.PUPTIME_SIGNING_PRIVATE??'');assert.ok(process.env.RUNNER_TEMP&&root.startsWith(resolve(process.env.RUNNER_TEMP)+'/puptime-signing-')); const source=process.env.PUPTIME_EXPECTED_SOURCE,driverSource=process.env.PUPTIME_EXPECTED_DRIVER;assert.match(source??'',/^[a-f0-9]{40}$/);validateDriver(driverSource,process.env.GITHUB_SHA);assertWidgetSource(); @@ -266,7 +296,9 @@ const inputs=JSON.parse(readFileSync(join(root,'inputs.json')));assert.equal(inp for(const [i,name] of ['bridge','later'].entries())assert.equal(await fileHash(join(root,name,'input.apk')),inputs.artifacts[i].sha256); const device='emulator-5588',adb=(...args)=>command('adb',['-s',device,...args]);assert.equal(adb('emu','avd','name').trim().split(/\r?\n/)[0],'Puptime_Signing_QA_'+process.env.GITHUB_RUN_ID);assert.equal(adb('shell','getprop','ro.build.version.sdk').trim(),'36'); assert.equal(adb('shell','settings','get','global','airplane_mode_on').trim(),'1');assert.equal(adb('shell','id','-u').trim(),'0');assert.equal(command('adb',['-s',device,'shell','pm','path',app],{ok:[1]}),'','The task emulator must not contain any pre-existing Puptime installation.'); -assert.equal(adb('shell','settings','get','global','wifi_on').trim(),'0');assert.equal(adb('shell','settings','get','global','mobile_data').trim(),'0'); +assert.equal(adb('shell','settings','get','global','wifi_on').trim(),'0'); +// Saved mobile_data may target a different/no subscription. Require actual network absence. +const offline=readOffline((args,timeout)=>({status:0,stdout:command('adb',['-s',device,...args],{timeout})}));writeFileSync(join(root,'emulator-host-state.json'),JSON.stringify(safeHostState({...JSON.parse(readFileSync(join(root,'emulator-host-state.json'))),...offline,offlinePreKey:offlineReady(offline)})),{mode:0o600});assert.ok(offlineReady(offline),'Pre-key actual offline readback failed.'); const tools=join(process.env.ANDROID_HOME,'build-tools/36.0.0'),signer=join(tools,'apksigner'),aapt=join(tools,'aapt2'); function artifact(path,code,cert){const verified=command(signer,['verify','--verbose','--print-certs',path]);assert.ok(verified.includes('certificate SHA-256 digest: '+cert));const manifest=command(aapt,['dump','badging',path]).split('\n').slice(0,3);assert.ok(manifest[0].includes(`name='${app}' versionCode='${code}'`));assert.equal(manifest[1],"minSdkVersion:'24'");assert.equal(manifest[2],"targetSdkVersion:'36'");} artifact(join(root,'original125.apk'),1012005,oldCert);for(const [i,name] of ['bridge','later'].entries())artifact(join(root,name,'input.apk'),1012006+i,oldCert); From 911beff8b5c4545add20d6928674223a56378a4c Mon Sep 17 00:00:00 2001 From: Patrick Chin Date: Sun, 15 Nov 2026 05:17:10 +0800 Subject: [PATCH 12/37] Record sanitized Android signing exercise stages and failures (#221) * Record finite Android signing exercise stages and failures * Describe exercise evidence without stale preflight scope --------- Co-authored-by: Patrick Chin <1392122+patrickchin@users.noreply.github.com> --- scripts/verify-android-signing-upgrade.mjs | 104 ++++++++++++++------- 1 file changed, 72 insertions(+), 32 deletions(-) diff --git a/scripts/verify-android-signing-upgrade.mjs b/scripts/verify-android-signing-upgrade.mjs index ae8c04b1..5b11f983 100644 --- a/scripts/verify-android-signing-upgrade.mjs +++ b/scripts/verify-android-signing-upgrade.mjs @@ -13,6 +13,31 @@ const oldCert='fac61745dc0903786fb9ede62a962b399f7348f0bb6f899b8332667591033b9c' const keySHA='221e0a3106aa4c3ccc154e0a418b55020b3f9ea6e84f92e8749cd9e2f39f5e58'; const caps={'installed data':true,'shared UID':false,permission:false,rollback:false,auth:false}; const hash=value=>createHash('sha256').update(value).digest('hex'); +const exerciseStages=['exercise-preconditions','original-artifact-validation','bridge-input-validation','later-input-validation','public-template-validation','qa-key-creation','qa-certificate-readback','lineage-creation','lineage-inspection','plain6-sign','plain6-verify','bridge6-sign','bridge6-verify','plain7-sign','plain7-verify','bridge-lineage-inspection','plain6-lineage-absence','plain7-lineage-absence','bridge-v31-validation','native-evidence-imports','original-install','original-launch','guest-seed','reminder-preference-seed','widget-binding','widget-first-save','snapshot-old-created','original-state-validation','plain-B-reject','snapshot-plain-B-rejected','plain-B-preservation','bridge-install','snapshot-bridge-before-launch','bridge-before-launch-preservation','bridge-launch','bridge-visible','snapshot-bridge-launched','bridge-state-validation','bridge-widget-save','snapshot-bridge-widget','bridge-widget-validation','higher-A-reject','snapshot-higher-A-rollback-rejected','higher-A-preservation','later-install','snapshot-later-before-launch','later-before-launch-preservation','later-launch','later-visible','snapshot-later-launched','later-state-validation','later-cold-restart','snapshot-later-cold','later-cold-preservation','exercise-finalize']; +const commandFailures=new WeakMap(); +function commandFailure(error,result){ + const value={class:result.error?.code==='ETIMEDOUT'?'timeout':result.error?.code==='ENOENT'?'missing-tool':'command-failed'}; + if(Number.isInteger(result.status)&&result.status>=0&&result.status<=255)value.status=result.status; + commandFailures.set(error,value);return error; +} +function finiteFailure(error){return commandFailures.get(error)??{class:error?.code==='ERR_ASSERTION'?'assertion':'other'};} +function exerciseRecorder(save){ + const history=[],active=[];let failure; + const snapshot=()=>({phase:active.at(-1)??history.at(-1)?.phase??'exercise-preconditions',state:failure?'failed':active.length?'started':'completed',history:history.map(({phase,state})=>({phase,state})),...(failure?{failure:{...failure}}:{})}); + const event=(phase,state)=>{assert.ok(exerciseStages.includes(phase));assert.ok(['started','completed','failed'].includes(state));assert.ok(history.length<256);history.push({phase,state});}; + return {begin(phase){event(phase,'started');active.push(phase);save(snapshot());},end(phase){assert.equal(active.at(-1),phase);event(phase,'completed');active.pop();save(snapshot());},persist(){save(snapshot());},fail(error){failure=finiteFailure(error);event(active.at(-1)??history.at(-1)?.phase??'exercise-preconditions','failed');save(snapshot());}}; +} +function exerciseEvidenceSelfCheck(){ + const secret='PRIVATE token=password key=/private/secret.pem stdout stderr stack';let saved;const make=()=>exerciseRecorder(value=>saved=JSON.parse(JSON.stringify(value))); + const rich=()=>Object.assign(new Error(secret),{stack:secret,argv:[secret],stdout:Buffer.from(secret),stderr:secret,key:secret,password:secret,path:secret,token:secret,status:secret}); + const errors=[{error:rich(),expected:{class:'other'}},{error:Object.assign(rich(),{code:'ERR_ASSERTION',actual:secret,expected:secret}),expected:{class:'assertion'}}]; + for(const [code,status,kind] of [[undefined,17,'command-failed'],['ETIMEDOUT',null,'timeout'],['ENOENT',null,'missing-tool'],[undefined,0,'command-failed']])errors.push({error:commandFailure(rich(),{status,error:{code,message:secret},stdout:secret,stderr:secret}),expected:{class:kind,...(status===null?{}:{status})}}); + for(const {error,expected} of errors){const record=make();record.begin('original-artifact-validation');record.fail(error);assert.equal(saved.phase,'original-artifact-validation');assert.equal(saved.state,'failed');assert.deepEqual(saved.failure,expected);assert.deepEqual(saved.history,[{phase:'original-artifact-validation',state:'started'},{phase:'original-artifact-validation',state:'failed'}]);const text=JSON.stringify(saved);for(const key of ['PRIVATE','password','argv','stdout','stderr','stack','token','path'])assert.ok(!text.includes(key));} + for(const status of [-1,256,NaN,'17',null])assert.deepEqual(finiteFailure(commandFailure(rich(),{status})),{class:'command-failed'}); + const record=make();record.begin('plain-B-preservation');record.begin('snapshot-plain-B-rejected');record.end('snapshot-plain-B-rejected');assert.equal(saved.phase,'plain-B-preservation');record.end('plain-B-preservation');assert.equal(saved.state,'completed');record.persist();assert.ok(!('failure' in saved));assert.throws(()=>record.begin(secret));assert.ok(!JSON.stringify(saved).includes(secret)); + console.log('PASS finite exercise stage/error/status evidence and secret-rich negative checks; no command, keys or device operations.'); +} + const toolingPaths=['.github/workflows/android-signing-proof.yml','scripts/verify-android-signing-upgrade.mjs']; function validateDriver(expected,actual){assert.match(expected??'',/^[a-f0-9]{40}$/);assert.equal(actual,expected,'Pin the reviewed driver SHA separately from artifact source.');} function equivalentChanges(raw){ @@ -72,6 +97,7 @@ function widgetBinding(text){ assert.ok(id&&host&&provider);return {id,host,provider}; } function selfCheck(){ + exerciseEvidenceSelfCheck(); emulatorSelfCheck(); offlineSelfCheck(); assertWidgetSource(); @@ -244,6 +270,10 @@ function emulatorHost(mode){ } const [mode]=process.argv.slice(2);if(['--emulator-record','--emulator-root','--emulator-offline','--emulator-cleanup'].includes(mode)){try{emulatorHost(mode);process.exit(0);}catch{console.error('FAIL host-only '+mode.slice(2)+'; raw process/error evidence remains private.');process.exit(1);}}if(mode==='--self-check'){selfCheck();process.exit(0);} +const exerciseReceipt=resolve('build/android-signing-safe/verification.json');let exercisePrivateRoot; +const exerciseEvidence=mode==='--exercise'?exerciseRecorder(value=>{mkdirSync(resolve(exerciseReceipt,'..'),{recursive:true,mode:0o700});const prior=existsSync(exerciseReceipt)?JSON.parse(readFileSync(exerciseReceipt)):{};writeFileSync(exerciseReceipt,JSON.stringify({...prior,...(value.failure?{status:'exercise-failed'}:{}),scope:'Guest-only API36 exercise stage evidence; no credential or Play acceptance. Started/completed markers do not establish later signing/data success.',exercise:value},null,2),{mode:0o600});}):null; +try{ +exerciseEvidence?.begin('exercise-preconditions'); assert.ok(['--preflight','--exercise'].includes(mode));assert.equal(process.env.GITHUB_ACTIONS,'true');assert.equal(process.env.RUNNER_OS,'Linux');process.umask(0o077); const root=resolve(process.env.PUPTIME_SIGNING_PRIVATE??'');assert.ok(process.env.RUNNER_TEMP&&root.startsWith(resolve(process.env.RUNNER_TEMP)+'/puptime-signing-')); const source=process.env.PUPTIME_EXPECTED_SOURCE,driverSource=process.env.PUPTIME_EXPECTED_DRIVER;assert.match(source??'',/^[a-f0-9]{40}$/);validateDriver(driverSource,process.env.GITHUB_SHA);assertWidgetSource(); @@ -252,7 +282,7 @@ function command(binary,args,{ok=[0],binaryOutput=false,timeout=120000}={}){ const result=spawnSync(binary,args,{encoding:binaryOutput?undefined:'utf8',timeout,maxBuffer:256*1024*1024,stdio:['ignore','pipe','pipe']}); if(!ok.includes(result.status)){ if(existsSync(root))writeFileSync(join(root,'last-command-failure.txt'),String(result.stdout??'').slice(0,32768)+'\n'+String(result.stderr??'').slice(0,32768),{mode:0o600}); - throw new Error(binary+' failed (exit '+result.status+'); private command/driver evidence was not uploaded.'); + throw commandFailure(new Error(binary+' failed (exit '+result.status+'); private command/driver evidence was not uploaded.'),result); } return binaryOutput?result.stdout:String(result.stdout??'')+(result.status?String(result.stderr??''):''); } @@ -291,7 +321,7 @@ if(mode==='--preflight'){ await download(`https://github.com/${repo}/releases/download/v1.12.5/Puptime-1.12.5.apk`,join(root,'original125.apk'),oldSHA); writeFileSync(join(root,'inputs.json'),JSON.stringify(inputs,null,2));mkdirSync(output,{recursive:true,mode:0o700});writeFileSync(join(output,'verification.json'),JSON.stringify({status:'preflight-passed',source,driverSource,sourceEquivalence,artifacts:inputs.artifacts,phases:[],scope:'Pinned equivalent driver/app inputs only; no keys or Android runtime.'},null,2));console.log('PASS pinned driver/input tree equivalence, artifact-only runs, ZIP/APK digests and exact source/version metadata. No signing or Android runtime.');process.exit(0); } -assert.equal(readFileSync(marker,'utf8'),source);assert.equal(lstatSync(root).mode&0o777,0o700); +assert.equal(readFileSync(marker,'utf8'),source);assert.equal(lstatSync(root).mode&0o777,0o700);exercisePrivateRoot=root; const inputs=JSON.parse(readFileSync(join(root,'inputs.json')));assert.equal(inputs.source,source);assert.equal(inputs.driverSource,driverSource);assert.deepEqual(inputs.sourceEquivalence,sourceEquivalence);assert.equal(await fileHash(join(root,'original125.apk')),oldSHA); for(const [i,name] of ['bridge','later'].entries())assert.equal(await fileHash(join(root,name,'input.apk')),inputs.artifacts[i].sha256); const device='emulator-5588',adb=(...args)=>command('adb',['-s',device,...args]);assert.equal(adb('emu','avd','name').trim().split(/\r?\n/)[0],'Puptime_Signing_QA_'+process.env.GITHUB_RUN_ID);assert.equal(adb('shell','getprop','ro.build.version.sdk').trim(),'36'); @@ -299,34 +329,36 @@ assert.equal(adb('shell','settings','get','global','airplane_mode_on').trim(),'1 assert.equal(adb('shell','settings','get','global','wifi_on').trim(),'0'); // Saved mobile_data may target a different/no subscription. Require actual network absence. const offline=readOffline((args,timeout)=>({status:0,stdout:command('adb',['-s',device,...args],{timeout})}));writeFileSync(join(root,'emulator-host-state.json'),JSON.stringify(safeHostState({...JSON.parse(readFileSync(join(root,'emulator-host-state.json'))),...offline,offlinePreKey:offlineReady(offline)})),{mode:0o600});assert.ok(offlineReady(offline),'Pre-key actual offline readback failed.'); +exerciseEvidence.end('exercise-preconditions'); const tools=join(process.env.ANDROID_HOME,'build-tools/36.0.0'),signer=join(tools,'apksigner'),aapt=join(tools,'aapt2'); function artifact(path,code,cert){const verified=command(signer,['verify','--verbose','--print-certs',path]);assert.ok(verified.includes('certificate SHA-256 digest: '+cert));const manifest=command(aapt,['dump','badging',path]).split('\n').slice(0,3);assert.ok(manifest[0].includes(`name='${app}' versionCode='${code}'`));assert.equal(manifest[1],"minSdkVersion:'24'");assert.equal(manifest[2],"targetSdkVersion:'36'");} -artifact(join(root,'original125.apk'),1012005,oldCert);for(const [i,name] of ['bridge','later'].entries())artifact(join(root,name,'input.apk'),1012006+i,oldCert); -const template=command('tar',['-xOf','node_modules/expo/template.tgz','package/android/app/debug.keystore'],{binaryOutput:true});assert.equal(hash(template),keySHA); -writeFileSync(join(root,'public-A.keystore'),template);writeFileSync(join(root,'A-password'),'android\n');writeFileSync(join(root,'B-password'),randomBytes(32).toString('hex')+'\n'); -command('keytool',['-genkeypair','-alias','qa-only','-keystore',join(root,'QA-only-B.p12'),'-storetype','PKCS12','-storepass:file',join(root,'B-password'),'-keyalg','RSA','-keysize','3072','-validity','2','-dname','CN=Puptime QA NOT FOR RELEASE','-noprompt']); -const b=hash(command('keytool',['-exportcert','-alias','qa-only','-keystore',join(root,'QA-only-B.p12'),'-storepass:file',join(root,'B-password')],{binaryOutput:true})); +exerciseEvidence.begin('original-artifact-validation');artifact(join(root,'original125.apk'),1012005,oldCert);exerciseEvidence.end('original-artifact-validation');for(const [i,name] of ['bridge','later'].entries()){const stage=i?'later-input-validation':'bridge-input-validation';exerciseEvidence.begin(stage);artifact(join(root,name,'input.apk'),1012006+i,oldCert);exerciseEvidence.end(stage);} +exerciseEvidence.begin('public-template-validation');const template=command('tar',['-xOf','node_modules/expo/template.tgz','package/android/app/debug.keystore'],{binaryOutput:true});assert.equal(hash(template),keySHA);exerciseEvidence.end('public-template-validation'); +exerciseEvidence.begin('qa-key-creation');writeFileSync(join(root,'public-A.keystore'),template);writeFileSync(join(root,'A-password'),'android\n');writeFileSync(join(root,'B-password'),randomBytes(32).toString('hex')+'\n'); +command('keytool',['-genkeypair','-alias','qa-only','-keystore',join(root,'QA-only-B.p12'),'-storetype','PKCS12','-storepass:file',join(root,'B-password'),'-keyalg','RSA','-keysize','3072','-validity','2','-dname','CN=Puptime QA NOT FOR RELEASE','-noprompt']);exerciseEvidence.end('qa-key-creation'); +exerciseEvidence.begin('qa-certificate-readback');const b=hash(command('keytool',['-exportcert','-alias','qa-only','-keystore',join(root,'QA-only-B.p12'),'-storepass:file',join(root,'B-password')],{binaryOutput:true}));exerciseEvidence.end('qa-certificate-readback'); const key=(name,alias)=>['--ks',join(root,name==='A'?'public-A.keystore':'QA-only-B.p12'),'--ks-pass','file:'+join(root,name+'-password'),'--ks-key-alias',alias]; const A=key('A','androiddebugkey'),B=key('B','qa-only'),lineage=join(root,'QA-only.lineage'); -command(signer,['rotate','--out',lineage,'--old-signer',...A,...Object.entries(caps).flatMap(([name,value])=>['--set-'+({'installed data':'installed-data','shared UID':'shared-uid'}[name]??name),String(value)]),'--new-signer',...B]); -inspectCapabilities(command(signer,['lineage','--in',lineage,'--print-certs','-v']),oldCert,b); +exerciseEvidence.begin('lineage-creation');command(signer,['rotate','--out',lineage,'--old-signer',...A,...Object.entries(caps).flatMap(([name,value])=>['--set-'+({'installed data':'installed-data','shared UID':'shared-uid'}[name]??name),String(value)]),'--new-signer',...B]);exerciseEvidence.end('lineage-creation'); +exerciseEvidence.begin('lineage-inspection');inspectCapabilities(command(signer,['lineage','--in',lineage,'--print-certs','-v']),oldCert,b);exerciseEvidence.end('lineage-inspection'); for(const [name,input,keys] of [['plain6','bridge',B],['bridge6','bridge',[...A,'--next-signer',...B,'--lineage',lineage,'--rotation-min-sdk-version','33']],['plain7','later',B]]){ - const target=join(root,name+'.apk');command(signer,['sign',...keys,'--out',target,join(root,input,'input.apk')]);artifact(target,name==='plain7'?1012007:1012006,b); - assert.deepEqual(command('unzip',['-p',target,'AndroidManifest.xml'],{binaryOutput:true}),command('unzip',['-p',join(root,input,'input.apk'),'AndroidManifest.xml'],{binaryOutput:true})); + const target=join(root,name+'.apk');exerciseEvidence.begin(name+'-sign');command(signer,['sign',...keys,'--out',target,join(root,input,'input.apk')]);exerciseEvidence.end(name+'-sign');exerciseEvidence.begin(name+'-verify');artifact(target,name==='plain7'?1012007:1012006,b); + assert.deepEqual(command('unzip',['-p',target,'AndroidManifest.xml'],{binaryOutput:true}),command('unzip',['-p',join(root,input,'input.apk'),'AndroidManifest.xml'],{binaryOutput:true}));exerciseEvidence.end(name+'-verify'); } -inspectCapabilities(command(signer,['lineage','--in',join(root,'bridge6.apk'),'--print-certs','-v']),oldCert,b); -for(const name of ['plain6','plain7'])assert.match(command(signer,['lineage','--in',join(root,name+'.apk'),'--print-certs'],{ok:[1]}),/does not contain a valid lineage/); -const bridgeVerification=command(signer,['verify','--verbose','--print-certs','--min-sdk-version','33','--max-sdk-version','36',join(root,'bridge6.apk')]);assert.ok(bridgeVerification.includes('Verified using v3.1 scheme (APK Signature Scheme v3.1): true')); +exerciseEvidence.begin('bridge-lineage-inspection');inspectCapabilities(command(signer,['lineage','--in',join(root,'bridge6.apk'),'--print-certs','-v']),oldCert,b);exerciseEvidence.end('bridge-lineage-inspection'); +for(const name of ['plain6','plain7']){exerciseEvidence.begin(name+'-lineage-absence');assert.match(command(signer,['lineage','--in',join(root,name+'.apk'),'--print-certs'],{ok:[1]}),/does not contain a valid lineage/);exerciseEvidence.end(name+'-lineage-absence');} +exerciseEvidence.begin('bridge-v31-validation');const bridgeVerification=command(signer,['verify','--verbose','--print-certs','--min-sdk-version','33','--max-sdk-version','36',join(root,'bridge6.apk')]);assert.ok(bridgeVerification.includes('Verified using v3.1 scheme (APK Signature Scheme v3.1): true'));exerciseEvidence.end('bridge-v31-validation'); -const {nativeSnapshot,assertNativeTransition}=await import('./native-account-evidence.mjs'); -const {eventDataFromSql}=await import('../src/record-sql.ts'); -const phases=[];function pass(phase,state){phases.push({phase,protectedStateSHA256:hash(JSON.stringify(state))});writeFileSync(join(output,'verification.json'),JSON.stringify({status:'in-progress',source,driverSource,sourceEquivalence,artifacts:inputs.artifacts,phases,scope:'Guest-only API36 x86; no credential or Play acceptance.'},null,2));console.log('PASS signing QA phase: '+phase);} +exerciseEvidence.begin('native-evidence-imports');const {nativeSnapshot,assertNativeTransition}=await import('./native-account-evidence.mjs'); +const {eventDataFromSql}=await import('../src/record-sql.ts');exerciseEvidence.end('native-evidence-imports'); +const phases=[];function pass(phase,state){phases.push({phase,protectedStateSHA256:hash(JSON.stringify(state))});writeFileSync(join(output,'verification.json'),JSON.stringify({status:'in-progress',source,driverSource,sourceEquivalence,artifacts:inputs.artifacts,phases,scope:'Guest-only API36 x86; no credential or Play acceptance.'},null,2));exerciseEvidence.persist();console.log('PASS signing QA phase: '+phase);} function flow(name,steps){const path=join(root,name+'.yaml');writeFileSync(path,`appId: ${app}\n---\n${steps}\n`);command('maestro',['--device',device,'test',path,'--test-output-dir',join(root,name+'-driver')],{timeout:240000});} const tap=id=>`- tapOn: {id: ${id}}\n`,scroll=id=>`- scrollUntilVisible:\n element: {id: ${id}}\n direction: DOWN\n visibilityPercentage: 100\n centerElement: true\n`; const settings=tap('nav.open')+'- assertVisible: {id: nav.close}\n'+tap('nav.settings')+'- assertVisible: {id: screen.settings}\n'; function launch(){adb('shell','am','start','-W','-n',app+'/.MainActivity');} async function install(path,replace=false){assert.match(adb('install',...(replace?['-r']:[]),path),/Success/);const installed=adb('shell','pm','path',app).trim();assert.match(installed,/^package:\/data\/app\/[^\n]+\/base\.apk$/);assert.equal(hash(command('adb',['-s',device,'exec-out','cat',installed.slice(8)],{binaryOutput:true})),await fileHash(path),'Installed APK must be the exact input.');} function snapshot(label){ + exerciseEvidence.begin('snapshot-'+label); adb('shell','am','force-stop',app);const dest=join(root,'snapshot-'+label);mkdirSync(dest,{mode:0o700});const files={}; for(const remote of ['files/SQLite','files/activity-backups','files/puppy-photos','files/dog-photos','shared_prefs']){ const path='/data/user/0/'+app+'/'+remote; @@ -338,23 +370,31 @@ function snapshot(label){ const db=new DatabaseSync(join(dest,'files/SQLite/puptime-v2.db'),{readOnly:true});db.exec('BEGIN');let tables;try{assert.equal(db.prepare('PRAGMA integrity_check').get().integrity_check,'ok');assert.deepEqual(db.prepare('PRAGMA foreign_key_check').all(),[]);tables=nativeSnapshot(db);}finally{db.exec('ROLLBACK');db.close();} const uid=adb('shell','dumpsys','package',app).match(/\buserId=(\d+)/)?.[1];assert.ok(uid); const widgets=widgetBinding(adb('shell','dumpsys','appwidget')); - return {tables,files,uid,widgets}; + exerciseEvidence.end('snapshot-'+label);return {tables,files,uid,widgets}; } function equal(before,after){assertNativeTransition(before.tables,after.tables);assert.equal(after.uid,before.uid);assert.deepEqual(after.widgets,before.widgets);assert.deepEqual(Object.fromEntries(Object.entries(after.files).map(([k,v])=>[k,v.sha256])),Object.fromEntries(Object.entries(before.files).map(([k,v])=>[k,v.sha256])));} function launched(before,after,version){assertNativeTransition(expectedLaunch(before.tables,version),after.tables);assert.equal(after.uid,before.uid);assert.deepEqual(after.widgets,before.widgets);protectedFiles(before.files,after.files,version,before.tables.events,eventDataFromSql,before.tables.puppies[0].name,1);} async function waitWidgetCount(count){const deadline=Date.now()+10000;do{const value=adb('shell',`sqlite3 -readonly /data/user/0/${app}/files/SQLite/puptime-v2.db "SELECT COUNT(*) FROM events WHERE source='widget';"`).trim();if(value===String(count)){await new Promise(done=>setTimeout(done,500));return;}await new Promise(done=>setTimeout(done,200));}while(Date.now()row.note==='Signing QA note'));assert.equal(before.tables.events.filter(row=>row.source==='widget').length,1);assert.deepEqual(before.tables.local_sync_owner,[]);assert.deepEqual(before.tables.local_sync_outbox,[]); -assert.equal(before.tables.metadata.find(row=>row.key==='backup_app_version')?.value,'1.12.5/1012005');const prefs=Object.fromEntries(before.tables.settings.map(row=>[row.key,row.value]));assert.equal(prefs['puptime.theme.v1'],'dark');assert.equal(prefs['puptime.language.v1'],'es');assert.ok(JSON.parse(prefs['puptime.widgetActions.v1']).includes('walk'));assert.equal(JSON.parse(prefs['puptime.notificationPreferences.v1']).reminderLeadMinutes,15);pass('original125-created-state',before); -rejectUpdate(join(root,'plain6.apk'),before,'plain-B-rejected');await install(join(root,'bridge6.apk'),true);equal(before,snapshot('bridge-before-launch'));launch();flow('bridge-visible','- extendedWaitUntil:\n visible: {id: screen.today}\n timeout: 90000\n');const bridge=snapshot('bridge-launched');launched(before,bridge,'1.12.6/1012006'); -launch();flow('bridge-cold','- extendedWaitUntil:\n visible: {id: screen.today}\n timeout: 90000\n- pressKey: Home\n- assertVisible: ^Log pee, last logged .+$\n- tapOn: ^Log pee, last logged .+$\n');await waitWidgetCount(2);const widget=snapshot('bridge-widget'); -const added=widget.tables.events.filter(row=>!bridge.tables.events.some(old=>old.id===row.id));assert.equal(added.length,1);assert.equal(added[0].type,'pee');assert.equal(added[0].source,'widget');const revisions=widget.tables.local_revisions.slice(bridge.tables.local_revisions.length);assert.equal(revisions.length,1);assert.equal(revisions[0].local_id,added[0].id);assert.equal(revisions[0].kind,'event');assert.equal(revisions[0].sequence,Math.max(0,...bridge.tables.local_revisions.map(row=>row.sequence))+1);assert.equal(revisions[0].pet_id,added[0].pet_id);assert.equal(revisions[0].source,'local');assert.ok(Number.isSafeInteger(revisions[0].created_at)&&revisions[0].created_at>=added[0].at);assert.equal(revisions[0].before_data,null);assert.deepEqual(JSON.parse(revisions[0].after_data),eventDataFromSql(added[0])); -assertNativeTransition(bridge.tables,widget.tables,{events:[...bridge.tables.events,added[0]],local_revisions:[...bridge.tables.local_revisions,revisions[0]],sqlite_sequence:bridge.tables.sqlite_sequence.map(row=>row.name==='local_revisions'?{...row,seq:row.seq+1}:row)});assert.equal(widget.uid,bridge.uid);assert.deepEqual(widget.widgets,bridge.widgets);protectedFiles(bridge.files,widget.files,'1.12.6/1012006',bridge.tables.events,eventDataFromSql,bridge.tables.puppies[0].name);pass('bridge-and-existing-widget-save',widget); -rejectUpdate(join(root,'later/input.apk'),widget,'higher-A-rollback-rejected');await install(join(root,'plain7.apk'),true);equal(widget,snapshot('later-before-launch'));launch();flow('later-visible','- extendedWaitUntil:\n visible: {id: screen.today}\n timeout: 90000\n');const later=snapshot('later-launched');launched(widget,later,'1.12.7/1012007');launch();flow('later-cold','- extendedWaitUntil:\n visible: {id: screen.today}\n timeout: 90000\n');equal(later,snapshot('later-cold'));pass('later-B-update-and-cold-restart',later); -writeFileSync(join(output,'verification.json'),JSON.stringify({status:'passed',source,driverSource,sourceEquivalence,artifacts:inputs.artifacts,phases,capabilities:caps,oldCertificate:oldCert,qaCertificate:b,scope:'Untouched125 guest→numeric QA bridge→numeric B update, API36 x86 only. No nonempty SecureStore/auth, genuine provider, ARM/OEM, Play or production-key acceptance.'},null,2)); +function rejectUpdate(path,before,label){const prefix=label==='plain-B-rejected'?'plain-B':'higher-A';exerciseEvidence.begin(prefix+'-reject');const result=command('adb',['-s',device,'install','-r',path],{ok:[1]});assert.match(result,/INSTALL_FAILED_UPDATE_INCOMPATIBLE/);exerciseEvidence.end(prefix+'-reject');exerciseEvidence.begin(prefix+'-preservation');equal(before,snapshot(label));pass(label,before);exerciseEvidence.end(prefix+'-preservation');} +exerciseEvidence.begin('original-install');await install(join(root,'original125.apk'));exerciseEvidence.end('original-install');exerciseEvidence.begin('original-launch');launch();exerciseEvidence.end('original-launch'); +exerciseEvidence.begin('guest-seed');flow('old-ui-seed',`- runFlow: ${JSON.stringify(resolve('.maestro/helpers/skip-onboarding.yaml'))}\n- assertVisible: {id: screen.today}\n- repeat:\n times: 4\n commands:\n - tapOn: {id: quick.pee}\n - tapOn: {id: quick.poop}\n - tapOn: {id: quick.meal}\n- tapOn: {id: event.meal.edit, index: 0}\n${scroll('editor.note.input')+tap('editor.note.input')}- inputText: Signing QA note\n- pressKey: Back\n- scrollUntilVisible:\n element: {id: editor.close}\n direction: UP\n${tap('editor.close')+settings}${tap('settings.widget.open')+scroll('settings.widget.action.walk')+tap('settings.widget.action.walk')+tap('settings.widget.close')+tap('settings.theme.open')+tap('appearance.theme.dark')+tap('settings.language.open')+tap('appearance.language.es')}- stopApp\n`);exerciseEvidence.end('guest-seed'); +exerciseEvidence.begin('reminder-preference-seed');launch(); +flow('old-reminder-lead','- extendedWaitUntil:\n visible: {id: screen.today}\n timeout: 90000\n'+settings+scroll('settings.notifications.open')+tap('settings.notifications.open')+scroll('settings.notifications.lead.15')+tap('settings.notifications.lead.15')+'- pressKey: Home\n');exerciseEvidence.end('reminder-preference-seed'); +exerciseEvidence.begin('widget-binding');flow('bind-real-launcher-widget',`- longPressOn:\n id: '.*:id/workspace'\n- tapOn: '^Widgets$'\n- scrollUntilVisible:\n element: {text: '^Puptime$'}\n direction: DOWN\n- tapOn: '^Puptime$'\n- tapOn: '^Puptime Quick Log$'\n- tapOn: '^Add$'\n- extendedWaitUntil:\n visible: '^Log pee, last logged .+$'\n timeout: 30000\n- tapOn: '^Log pee, last logged .+$'\n`);exerciseEvidence.end('widget-binding'); +exerciseEvidence.begin('widget-first-save');await waitWidgetCount(1);exerciseEvidence.end('widget-first-save');const before=snapshot('old-created');exerciseEvidence.begin('original-state-validation');assert.equal(before.tables.events.length,13);assert.ok(before.tables.events.some(row=>row.note==='Signing QA note'));assert.equal(before.tables.events.filter(row=>row.source==='widget').length,1);assert.deepEqual(before.tables.local_sync_owner,[]);assert.deepEqual(before.tables.local_sync_outbox,[]); +assert.equal(before.tables.metadata.find(row=>row.key==='backup_app_version')?.value,'1.12.5/1012005');const prefs=Object.fromEntries(before.tables.settings.map(row=>[row.key,row.value]));assert.equal(prefs['puptime.theme.v1'],'dark');assert.equal(prefs['puptime.language.v1'],'es');assert.ok(JSON.parse(prefs['puptime.widgetActions.v1']).includes('walk'));assert.equal(JSON.parse(prefs['puptime.notificationPreferences.v1']).reminderLeadMinutes,15);pass('original125-created-state',before);exerciseEvidence.end('original-state-validation'); +rejectUpdate(join(root,'plain6.apk'),before,'plain-B-rejected');exerciseEvidence.begin('bridge-install');await install(join(root,'bridge6.apk'),true);exerciseEvidence.end('bridge-install');exerciseEvidence.begin('bridge-before-launch-preservation');equal(before,snapshot('bridge-before-launch'));exerciseEvidence.end('bridge-before-launch-preservation');exerciseEvidence.begin('bridge-launch');launch();exerciseEvidence.end('bridge-launch');exerciseEvidence.begin('bridge-visible');flow('bridge-visible','- extendedWaitUntil:\n visible: {id: screen.today}\n timeout: 90000\n');exerciseEvidence.end('bridge-visible');const bridge=snapshot('bridge-launched');exerciseEvidence.begin('bridge-state-validation');launched(before,bridge,'1.12.6/1012006');exerciseEvidence.end('bridge-state-validation'); +exerciseEvidence.begin('bridge-widget-save');launch();flow('bridge-cold','- extendedWaitUntil:\n visible: {id: screen.today}\n timeout: 90000\n- pressKey: Home\n- assertVisible: ^Log pee, last logged .+$\n- tapOn: ^Log pee, last logged .+$\n');await waitWidgetCount(2);exerciseEvidence.end('bridge-widget-save');const widget=snapshot('bridge-widget'); +exerciseEvidence.begin('bridge-widget-validation');const added=widget.tables.events.filter(row=>!bridge.tables.events.some(old=>old.id===row.id));assert.equal(added.length,1);assert.equal(added[0].type,'pee');assert.equal(added[0].source,'widget');const revisions=widget.tables.local_revisions.slice(bridge.tables.local_revisions.length);assert.equal(revisions.length,1);assert.equal(revisions[0].local_id,added[0].id);assert.equal(revisions[0].kind,'event');assert.equal(revisions[0].sequence,Math.max(0,...bridge.tables.local_revisions.map(row=>row.sequence))+1);assert.equal(revisions[0].pet_id,added[0].pet_id);assert.equal(revisions[0].source,'local');assert.ok(Number.isSafeInteger(revisions[0].created_at)&&revisions[0].created_at>=added[0].at);assert.equal(revisions[0].before_data,null);assert.deepEqual(JSON.parse(revisions[0].after_data),eventDataFromSql(added[0])); +assertNativeTransition(bridge.tables,widget.tables,{events:[...bridge.tables.events,added[0]],local_revisions:[...bridge.tables.local_revisions,revisions[0]],sqlite_sequence:bridge.tables.sqlite_sequence.map(row=>row.name==='local_revisions'?{...row,seq:row.seq+1}:row)});assert.equal(widget.uid,bridge.uid);assert.deepEqual(widget.widgets,bridge.widgets);protectedFiles(bridge.files,widget.files,'1.12.6/1012006',bridge.tables.events,eventDataFromSql,bridge.tables.puppies[0].name);pass('bridge-and-existing-widget-save',widget);exerciseEvidence.end('bridge-widget-validation'); +rejectUpdate(join(root,'later/input.apk'),widget,'higher-A-rollback-rejected');exerciseEvidence.begin('later-install');await install(join(root,'plain7.apk'),true);exerciseEvidence.end('later-install');exerciseEvidence.begin('later-before-launch-preservation');equal(widget,snapshot('later-before-launch'));exerciseEvidence.end('later-before-launch-preservation');exerciseEvidence.begin('later-launch');launch();exerciseEvidence.end('later-launch');exerciseEvidence.begin('later-visible');flow('later-visible','- extendedWaitUntil:\n visible: {id: screen.today}\n timeout: 90000\n');exerciseEvidence.end('later-visible');const later=snapshot('later-launched');exerciseEvidence.begin('later-state-validation');launched(widget,later,'1.12.7/1012007');exerciseEvidence.end('later-state-validation');exerciseEvidence.begin('later-cold-restart');launch();flow('later-cold','- extendedWaitUntil:\n visible: {id: screen.today}\n timeout: 90000\n');exerciseEvidence.end('later-cold-restart');exerciseEvidence.begin('later-cold-preservation');equal(later,snapshot('later-cold'));pass('later-B-update-and-cold-restart',later);exerciseEvidence.end('later-cold-preservation'); +exerciseEvidence.begin('exercise-finalize');writeFileSync(join(output,'verification.json'),JSON.stringify({status:'passed',source,driverSource,sourceEquivalence,artifacts:inputs.artifacts,phases,capabilities:caps,oldCertificate:oldCert,qaCertificate:b,scope:'Untouched125 guest→numeric QA bridge→numeric B update, API36 x86 only. No nonempty SecureStore/auth, genuine provider, ARM/OEM, Play or production-key acceptance.'},null,2)); for(const path of readdirSync(root,{recursive:true}))if(lstatSync(join(root,path)).isFile())chmodSync(join(root,path),0o600); + +exerciseEvidence.end('exercise-finalize'); +}catch(error){ + if(mode!=='--exercise')throw error; + try{if(exercisePrivateRoot&&existsSync(exercisePrivateRoot))writeFileSync(join(exercisePrivateRoot,'exercise-failure.txt'),String(error.stack??error),{mode:0o600});}catch{} + try{exerciseEvidence.fail(error);}catch{console.error('FAIL safe exercise receipt could not be persisted.');} + console.error('FAIL signing QA exercise; only finite stage/error evidence is retained in the safe receipt.');process.exitCode=1; +} From e0e29813a71c99f12bc64bf730c764ef900add37 Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 05:33:25 +0800 Subject: [PATCH 13/37] Wire explicit incomplete notice discovery and candidate gates --- .github/workflows/android-apk.yml | 117 ++++++++++++++++++---------- .github/workflows/ios-simulator.yml | 107 ++++++++++++++++++++++++- 2 files changed, 179 insertions(+), 45 deletions(-) diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index de0f6aea..ee930ff7 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version: - description: Artifact version (for example 1.12.0); leave blank for the next patch + description: Must match committed app.json; blank uses its version required: false type: string publish: @@ -26,6 +26,9 @@ concurrency: jobs: build: runs-on: ubuntu-latest + env: + EXPO_PUBLIC_APP_POSTHOG_KEY: ${{ vars.EXPO_PUBLIC_APP_POSTHOG_KEY }} + EXPO_PUBLIC_APP_POSTHOG_HOST: ${{ vars.EXPO_PUBLIC_APP_POSTHOG_HOST || 'https://eu.i.posthog.com' }} outputs: tag: ${{ steps.release.outputs.tag }} version: ${{ steps.release.outputs.version }} @@ -37,52 +40,39 @@ jobs: fetch-depth: 0 ref: ${{ github.sha }} persist-credentials: false - - name: Choose release version + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - name: Validate committed candidate version id: release - shell: bash env: PUPTIME_REQUESTED_VERSION: ${{ inputs.version }} - run: | - if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then - tag="$GITHUB_REF_NAME" - base_tag="" - else - base_tag=$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' HEAD) - if [[ ! "$base_tag" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then - echo "Unsupported release tag: $base_tag" >&2 - exit 1 - fi - if [[ -n "$PUPTIME_REQUESTED_VERSION" ]]; then - tag="v$PUPTIME_REQUESTED_VERSION" - else - tag="v${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))" - fi - fi - if [[ ! "$tag" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then - echo "Unsupported release tag: $tag" >&2 - exit 1 - fi - { - echo "tag=$tag" - echo "version=${tag#v}" - echo "base_tag=$base_tag" - } >> "$GITHUB_OUTPUT" - - name: Set artifact version - env: - PUPTIME_VERSION: ${{ steps.release.outputs.version }} run: | node - <<'NODE' + const assert = require('node:assert/strict'); const fs = require('node:fs'); - const config = require('./app.json'); - const [major, minor, patch] = process.env.PUPTIME_VERSION.split('.').map(Number); - config.expo.version = process.env.PUPTIME_VERSION; - config.expo.android.versionCode = major * 1000000 + minor * 1000 + patch; - fs.writeFileSync('app.json', `${JSON.stringify(config, null, 2)}\n`); + const {execFileSync} = require('node:child_process'); + const {expo} = JSON.parse(fs.readFileSync('app.json', 'utf8')); + const match = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/.exec(expo.version); + assert.ok(match, 'Commit a valid candidate version before building.'); + const [major, minor, patch] = match.slice(1).map(Number); + const code = major * 1000000 + minor * 1000 + patch; + assert.ok(Number.isSafeInteger(code) && code > 0 && expo.android.versionCode === code, + 'Commit the matching Android versionCode before building.'); + const requested = (process.env.PUPTIME_REQUESTED_VERSION || '').trim(); + assert.ok(!requested || requested === expo.version, 'Requested version must equal committed app.json.'); + const tag = 'v' + expo.version; + const isTag = process.env.GITHUB_REF_TYPE === 'tag'; + assert.ok(!isTag || process.env.GITHUB_REF_NAME === tag, 'Tag must equal committed app.json.'); + execFileSync('git', ['diff', '--quiet', 'HEAD', '--'], {stdio: 'pipe'}); + const base = isTag ? '' : execFileSync('git', + ['describe', '--tags', '--abbrev=0', '--match', 'v[0-9]*.[0-9]*.[0-9]*', 'HEAD'], + {encoding: 'utf8'}).trim(); + assert.ok(!base || /^v[0-9]+\.[0-9]+\.[0-9]+$/.test(base), 'Invalid previous release tag.'); + fs.appendFileSync(process.env.GITHUB_OUTPUT, + 'tag=' + tag + '\nversion=' + expo.version + '\nbase_tag=' + base + '\n'); NODE - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: npm - uses: actions/setup-java@v5 with: distribution: temurin @@ -91,16 +81,37 @@ jobs: - run: npx expo prebuild --platform android --no-install env: CI: "1" + - name: Resolve notice inputs and seal committed candidate + run: | + umask 077 + mkdir -p build/notices/android/private + (cd android && ./gradlew -I ../scripts/notices-runtime.init.gradle \ + :app:writeNoticeRuntimeInputs --no-daemon \ + -PnoticeInputsOutput="$PWD/../build/notices/android/private/runtime-inputs.json") + node scripts/capture-app-notices.mjs seal android \ + --gradle-input build/notices/android/private/runtime-inputs.json - run: ./gradlew assembleRelease bundleRelease --no-daemon - env: - EXPO_PUBLIC_APP_POSTHOG_KEY: ${{ vars.EXPO_PUBLIC_APP_POSTHOG_KEY }} - EXPO_PUBLIC_APP_POSTHOG_HOST: ${{ vars.EXPO_PUBLIC_APP_POSTHOG_HOST || 'https://eu.i.posthog.com' }} working-directory: android + - name: Verify exact packaged notices and mapped build outputs + run: | + node scripts/capture-app-notices.mjs verify android \ + --gradle-input build/notices/android/private/runtime-inputs.json \ + --archive android/app/build/outputs/apk/release/app-release.apk \ + --bundle android/app/build/generated/assets/react/release/index.android.bundle \ + --source-map android/app/build/generated/sourcemaps/react/release/index.android.bundle.map + node scripts/capture-app-notices.mjs verify android \ + --gradle-input build/notices/android/private/runtime-inputs.json \ + --archive android/app/build/outputs/bundle/release/app-release.aab \ + --bundle android/app/build/generated/assets/react/release/index.android.bundle \ + --source-map android/app/build/generated/sourcemaps/react/release/index.android.bundle.map - name: Verify artifacts and record qualification metadata id: verify shell: bash run: | mkdir qualification + cp build/notices/android/seal.json qualification/NOTICE-SEAL.json + cp build/notices/android/final-apk.json qualification/NOTICE-APK.json + cp build/notices/android/final-aab.json qualification/NOTICE-AAB.json puptime_apksigner=$(find "$ANDROID_HOME/build-tools" -type f -name apksigner | sort -V | tail -n 1) test -n "$puptime_apksigner" "$puptime_apksigner" verify --verbose --print-certs android/app/build/outputs/apk/release/app-release.apk | tee qualification/APK-SIGNING.txt @@ -147,6 +158,26 @@ jobs: path: qualification/ if-no-files-found: error + - name: Upload unreviewed public notice inputs (INCOMPLETE) + if: always() + uses: actions/upload-artifact@v6 + with: + name: unreviewed-oss-notice-inputs-android + path: build/notices/android/review/unreviewed-oss-notice-inputs.json + if-no-files-found: warn + retention-days: 3 + + - name: Upload discovery metadata (INCOMPLETE) + if: always() + uses: actions/upload-artifact@v6 + with: + name: notice-discovery-metadata-android-INCOMPLETE + path: | + build/notices/android/discovery.json + build/notices/android/failure.json + if-no-files-found: warn + retention-days: 3 + publish: if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish == true }} needs: build diff --git a/.github/workflows/ios-simulator.yml b/.github/workflows/ios-simulator.yml index 0587be6d..5405d12f 100644 --- a/.github/workflows/ios-simulator.yml +++ b/.github/workflows/ios-simulator.yml @@ -10,8 +10,13 @@ on: suite: description: Test suite (PRs always retain the full suite) type: choice - options: [full, accounts] + options: [full, accounts, notices] default: full + notice_platform: + description: Dependency setup/notice discovery platform (suite=notices only) + type: choice + options: [ios, android] + default: ios native_host_run_id: description: Main iOS run supplying the unchanged native host (full success by default) type: string @@ -34,7 +39,7 @@ on: jobs: build: - if: github.event_name != 'workflow_dispatch' || inputs.suite != 'accounts' + if: github.event_name != 'workflow_dispatch' || (inputs.suite != 'accounts' && inputs.suite != 'notices') runs-on: macos-26 steps: - uses: actions/checkout@v6 @@ -306,3 +311,101 @@ jobs: path: build/native-runtime-replacement if-no-files-found: warn retention-days: 3 + + notice-discovery: + name: Notice discovery (INCOMPLETE) + if: github.event_name == 'workflow_dispatch' && inputs.suite == 'notices' + permissions: + contents: read + runs-on: ${{ inputs.notice_platform == 'ios' && 'macos-26' || 'ubuntu-latest' }} + timeout-minutes: 60 + env: + EXPO_PUBLIC_APP_POSTHOG_KEY: ${{ vars.EXPO_PUBLIC_APP_POSTHOG_KEY }} + EXPO_PUBLIC_APP_POSTHOG_HOST: ${{ vars.EXPO_PUBLIC_APP_POSTHOG_HOST || 'https://eu.i.posthog.com' }} + steps: + - name: Validate discovery platform + env: + PUPTIME_NOTICE_PLATFORM: ${{ inputs.notice_platform }} + run: | + case "$PUPTIME_NOTICE_PLATFORM" in + ios|android) ;; + *) exit 1 ;; + esac + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - uses: actions/setup-java@v5 + if: inputs.notice_platform == 'android' + with: + distribution: temurin + java-version: 17 + - run: npm ci + - name: Prepare iOS dependency inputs + if: inputs.notice_platform == 'ios' + env: + CI: '1' + run: | + npx expo prebuild --platform ios --no-install + pod install --project-directory=ios + - name: Collect iOS notices (INCOMPLETE; no candidate) + if: inputs.notice_platform == 'ios' + run: node scripts/capture-app-notices.mjs collect ios + - name: Prepare Android dependency inputs + if: inputs.notice_platform == 'android' + env: + CI: '1' + run: | + umask 077 + npx expo prebuild --platform android --no-install + mkdir -p build/notices/android/private + (cd android && ./gradlew -I ../scripts/notices-runtime.init.gradle \ + :app:writeNoticeRuntimeInputs --no-daemon \ + -PnoticeInputsOutput="$PWD/../build/notices/android/private/runtime-inputs.json") + - name: Collect Android notices (INCOMPLETE; no candidate) + if: inputs.notice_platform == 'android' + run: | + node scripts/capture-app-notices.mjs collect android \ + --gradle-input build/notices/android/private/runtime-inputs.json + - name: Record discovery-only scope + if: always() + run: echo 'Notice discovery is INCOMPLETE. No app compilation, signing, tests or publication were requested by this workflow.' >> "$GITHUB_STEP_SUMMARY" + - name: Upload unreviewed public iOS notice inputs (INCOMPLETE) + if: always() && inputs.notice_platform == 'ios' + uses: actions/upload-artifact@v6 + with: + name: unreviewed-oss-notice-inputs-ios + path: build/notices/ios/review/unreviewed-oss-notice-inputs.json + if-no-files-found: warn + retention-days: 3 + - name: Upload unreviewed public Android notice inputs (INCOMPLETE) + if: always() && inputs.notice_platform == 'android' + uses: actions/upload-artifact@v6 + with: + name: unreviewed-oss-notice-inputs-android + path: build/notices/android/review/unreviewed-oss-notice-inputs.json + if-no-files-found: warn + retention-days: 3 + - name: Upload iOS discovery metadata (INCOMPLETE) + if: always() && inputs.notice_platform == 'ios' + uses: actions/upload-artifact@v6 + with: + name: notice-discovery-metadata-ios-INCOMPLETE + path: | + build/notices/ios/discovery.json + build/notices/ios/failure.json + if-no-files-found: warn + retention-days: 3 + - name: Upload Android discovery metadata (INCOMPLETE) + if: always() && inputs.notice_platform == 'android' + uses: actions/upload-artifact@v6 + with: + name: notice-discovery-metadata-android-INCOMPLETE + path: | + build/notices/android/discovery.json + build/notices/android/failure.json + if-no-files-found: warn + retention-days: 3 From dd4a94d74db9abf40910bfe5a126d1ddbdff8b9e Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 06:09:00 +0800 Subject: [PATCH 14/37] Diagnose native notice inputs and scope Gradle collection to main --- .github/workflows/android-apk.yml | 1 + .github/workflows/ios-simulator.yml | 1 + docs/app-notices.md | 19 ++++++++++- scripts/app-notices.test.mjs | 19 +++++++++++ scripts/capture-app-notices.mjs | 24 ++++++++------ scripts/native-notice-inputs.mjs | 32 +++++++++++++++---- scripts/native-notice-inputs.test.mjs | 19 ++++++++++- scripts/notices-runtime-root.test.groovy | 40 ++++++++++++++++++++++++ scripts/notices-runtime.init.gradle | 9 ++++++ 9 files changed, 147 insertions(+), 17 deletions(-) create mode 100644 scripts/notices-runtime-root.test.groovy diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index ee930ff7..69343510 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -87,6 +87,7 @@ jobs: mkdir -p build/notices/android/private (cd android && ./gradlew -I ../scripts/notices-runtime.init.gradle \ :app:writeNoticeRuntimeInputs --no-daemon \ + -PnoticeMainProjectRoot="$PWD" \ -PnoticeInputsOutput="$PWD/../build/notices/android/private/runtime-inputs.json") node scripts/capture-app-notices.mjs seal android \ --gradle-input build/notices/android/private/runtime-inputs.json diff --git a/.github/workflows/ios-simulator.yml b/.github/workflows/ios-simulator.yml index 5405d12f..76d50592 100644 --- a/.github/workflows/ios-simulator.yml +++ b/.github/workflows/ios-simulator.yml @@ -364,6 +364,7 @@ jobs: mkdir -p build/notices/android/private (cd android && ./gradlew -I ../scripts/notices-runtime.init.gradle \ :app:writeNoticeRuntimeInputs --no-daemon \ + -PnoticeMainProjectRoot="$PWD" \ -PnoticeInputsOutput="$PWD/../build/notices/android/private/runtime-inputs.json") - name: Collect Android notices (INCOMPLETE; no candidate) if: inputs.notice_platform == 'android' diff --git a/docs/app-notices.md b/docs/app-notices.md index 277a83a7..ef41633b 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -39,6 +39,7 @@ umask 077 mkdir -p build/notices/android/private (cd android && ./gradlew -I ../scripts/notices-runtime.init.gradle \ :app:writeNoticeRuntimeInputs --no-daemon \ + -PnoticeMainProjectRoot="$PWD" \ -PnoticeInputsOutput="$PWD/../build/notices/android/private/runtime-inputs.json") node scripts/capture-app-notices.mjs collect android \ --gradle-input build/notices/android/private/runtime-inputs.json @@ -58,7 +59,23 @@ node scripts/capture-app-notices.mjs verify android \ `collect` writes `build/notices//discovery.json`, always `status: INCOMPLETE`, `complete: false`, `discoveryOnly: true`. Even fully mapped discovery inputs are not certified. It produces neither a payload nor a seal. -Unexpected graph/source/archive errors still fail the command. Reports expose +Unexpected graph/source/archive errors still fail the command. `failure.json` +may add a `native` object containing fixed operation/component/category enums and +an optional SHA-256 component identifier. It never includes raw errors, messages, +causes, paths, notice bodies or environment values. The most specific failing +Pod operation survives outer graph/union context. No bound is raised on failure. + +The Gradle init script requires an explicit absolute `noticeMainProjectRoot` and +compares canonical project directories before registering its task. Other +included builds are skipped; a missing `:app` on the selected root still fails. +This uses Gradle's public [root project](https://docs.gradle.org/current/javadoc/org/gradle/api/invocation/Gradle.html#getRootProject()) +and [project directory](https://docs.gradle.org/current/javadoc/org/gradle/api/Project.html#getProjectDir()) APIs. +The source-only root guard regression executes this actual init script through +`groovy.ui.GroovyMain scripts/notices-runtime-root.test.groovy scripts/notices-runtime.init.gradle` +with the installed Gradle distribution's `lib/*` on the Java classpath. It stubs +Gradle callbacks/task registration only; it starts no build or resolver. + +Reports expose relative source names, versions, hashes and finite unresolved reasons, not cache paths, licence source bodies, raw maps, environment values or application data. diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs index d5363a8c..fe5f35e2 100644 --- a/scripts/app-notices.test.mjs +++ b/scripts/app-notices.test.mjs @@ -176,3 +176,22 @@ test('actual verify CLI derives final components from map/native inputs and reje const extra={...sourceMap,sources:[...sourceMap.sources,'node_modules/extra/index.js'],sourcesContent:[...sourceMap.sourcesContent,'module.exports=0;']};file('build/actual.map',extra);assert.equal(verify().status,1); file('build/actual.map',sourceMap);pack(true);assert.equal(verify().status,1,'Unmapped extensionless Mach-O must fail through the production CLI.'); })); + + +test('actual failure CLI retains only finite Pod context and no private path, body, environment or stack',()=>fixture(({root,file})=>{ + const git=(...args)=>execFileSync('git',['-c','core.hooksPath=/dev/null','-c','commit.gpgsign=false',...args],{cwd:root,stdio:'pipe'}); + file('.gitignore','build/\nnode_modules/\n');file('index.ts','export const fixture=1;'); + file('package-lock.json',{packages:{}});file('node_modules/qa/package.json',{name:'qa',version:'1'}); + file('scripts/app-notice-provenance.json',{schema:1,components:[]});file('scripts/font-icon-provenance.json',fonts); + file('ios/Podfile.lock','PODS:\n - QA (1)\n'); + file('ios/Pods/Target Support Files/Pods-QA/Pods-QA-acknowledgements.plist','PRIVATE_NOTICE_BODY\0'); + git('init','--quiet');git('add','.');git('-c','user.name=Notice test','-c','user.email=notice-test@example.invalid','commit','--quiet','-m','Synthetic finite failure'); + const sourceCommit=git('rev-parse','HEAD').toString().trim(); + file('build/notices/ios/seal.json',{sourceCommit,platform:'ios',sources:[{path:'index.ts'}]}); + const result=spawnSync(process.execPath,[fileURLToPath(new URL('./capture-app-notices.mjs',import.meta.url)),'verify','ios','--archive',join(root,'build/never-built.ipa')],{cwd:root,encoding:'utf8',timeout:30000,env:{...process.env,PRIVATE_NOTICE_ENV:'SECRET_ENV_VALUE'}}); + assert.equal(result.status,1); + const failure=JSON.parse(readFileSync(join(root,'build/notices/ios/failure.json'))); + assert.deepEqual(failure,{schema:1,status:'INCOMPLETE',complete:false,sourceCommit,platform:'ios',stage:'source',native:{operation:'pod-acknowledgements',component:'cocoapods',errorCategory:'invalid-utf8'}}); + const output=JSON.stringify(failure)+result.stdout+result.stderr; + for(const secret of [root,'PRIVATE_NOTICE_BODY','SECRET_ENV_VALUE','stack','cause','at podNoticeInputs'])assert.ok(!output.includes(secret)); +})); diff --git a/scripts/capture-app-notices.mjs b/scripts/capture-app-notices.mjs index 6415dda3..7a4e14b5 100644 --- a/scripts/capture-app-notices.mjs +++ b/scripts/capture-app-notices.mjs @@ -6,7 +6,7 @@ import {createRequire} from 'node:module'; import {dirname,join,relative,resolve,sep} from 'node:path'; import {fileURLToPath} from 'node:url'; import {addReviewedNoticeFiles,assertCapturedSources,assertFinalNoticeEvidence,assertFinalSourceMap,assertPackagedNativeCoverage,assertSourceNoticePayload,capturedSource,digest,npmNoticeInputs,noticeText,publicNoticeReport,resolveNoticeCoverage,unreviewedOSSNoticeInputs} from './app-notices.mjs'; -import {gradleNoticeInputs,inspectNoticeArchive,podNoticeInputs,unionNoticeInputs} from './native-notice-inputs.mjs'; +import {gradleNoticeInputs,inspectNoticeArchive,podNoticeInputs,unionNoticeInputs,noticeInputStep,noticeFailureContext} from './native-notice-inputs.mjs'; import {installTaskCleanup} from './native-task-cleanup.mjs'; export const payloadPath='assets/notices/third_party_notices.json'; @@ -21,16 +21,21 @@ function nativeInputs(root,platform,gradleInput){return platform==='ios'?podNoti // Injecting paths permits meaningful fixture checks without starting a bundler. export function captureResolvedInputs(root,platform,paths,gradleInput){ assert.ok(paths.length<=100000,'Metro graph exceeds the capture bound.'); - const ledger=readJSON(join(root,ledgerPath)),fonts=readJSON(join(root,fontPath)),unresolved=[],sources=[],physical=[]; - for(const path of new Set(paths)){ + const [ledger,fonts]=noticeInputStep('review-inputs','review',()=>[readJSON(join(root,ledgerPath)),readJSON(join(root,fontPath))]),unresolved=[],sources=[],physical=[]; + noticeInputStep('source-graph','metro',()=>{for(const path of new Set(paths)){ if(path==='__prelude__'&&!Object.hasOwn(ledger.generatedSources??{},path)){unresolved.push({component:'metro:__prelude__',reason:'unreviewed-generated-source'});continue;} const value=capturedSource(root,path,ledger);sources.push(value); physical.push(join(root,value.generated?value.provider:value.path)); } - const native=nativeInputs(root,platform,gradleInput); - const components=addReviewedNoticeFiles(root,unionNoticeInputs(npmNoticeInputs(root,physical),native.components),ledger,platform); - assert.ok(components.length<=2048&&components.reduce((bytes,item)=>bytes+item.notices.reduce((sum,value)=>sum+Buffer.byteLength(value.body),0),0)<=16*1024*1024,'Notice inputs exceed the review bound.'); - const coverage=resolveNoticeCoverage(components,ledger,platform);coverage.unresolved.push(...unresolved,...native.unresolved); + }); + const native=noticeInputStep('native-inputs',platform,()=>nativeInputs(root,platform,gradleInput)); + const npm=noticeInputStep('npm-notices','npm',()=>npmNoticeInputs(root,physical)); + const union=noticeInputStep('notice-union','review',()=>unionNoticeInputs(npm,native.components)); + const components=noticeInputStep('reviewed-notices','review',()=>{ + const result=addReviewedNoticeFiles(root,union,ledger,platform); + assert.ok(result.length<=2048&&result.reduce((bytes,item)=>bytes+item.notices.reduce((sum,value)=>sum+Buffer.byteLength(value.body),0),0)<=16*1024*1024,'Notice inputs exceed the review bound.');return result; + }); + const coverage=noticeInputStep('coverage','review',()=>resolveNoticeCoverage(components,ledger,platform));coverage.unresolved.push(...unresolved,...native.unresolved); if(coverage.unresolved.length)coverage.status='INCOMPLETE'; return {ledger,fonts,coverage,sources:sources.sort((a,b)=>a.path.localeCompare(b.path)),native,noticeInputs:components}; } @@ -131,8 +136,9 @@ export async function main(args=process.argv.slice(2)){ if(mode==='collect'){console.log(`Notices discovery: INCOMPLETE (${capture.coverage.unresolved.length} unresolved inputs); no release payload generated.`);return;} stage='seal';write(join(out,'seal.json'),createNoticeSeal(root,platform,capture)); console.log('Notices precompile verification: READY.'); - }catch{ - write(join(out,'failure.json'),{schema:1,status:'INCOMPLETE',complete:false,sourceCommit,platform,stage}); + }catch(error){ + const native=noticeFailureContext(error); + write(join(out,'failure.json'),{schema:1,status:'INCOMPLETE',complete:false,sourceCommit,platform,stage,...(native?{native}:{})}); console.error(`Notices verification failed closed at ${stage}; no completed payload or release qualification.`);process.exitCode=1; } } diff --git a/scripts/native-notice-inputs.mjs b/scripts/native-notice-inputs.mjs index b436eaa6..cefebd0f 100644 --- a/scripts/native-notice-inputs.mjs +++ b/scripts/native-notice-inputs.mjs @@ -6,19 +6,38 @@ import {createRequire} from 'node:module'; import {digest,nativeNoticeInput,npmNoticeInputs,nativeSourceNotices,noticeText} from './app-notices.mjs'; const require=createRequire(import.meta.url),archiveReader=new URL('./notice-archive.py',import.meta.url); + +// Only finite diagnostics escape the CLI catch. Never serialize an Error, +// message, cause, path or unvalidated dependency name into review artifacts. +const operations=new Set(['review-inputs','source-graph','native-inputs','npm-notices','notice-union','reviewed-notices','coverage','pod-lock','pod-lock-entries','pod-acknowledgements','pod-source-scan','pod-notices']); +const components=new Set(['review','metro','ios','android','npm','cocoapods']); +const contexts=new WeakMap(); +const assertionCategories=new Map([['Notice must be readable UTF-8.','invalid-utf8'],['Native source link escapes its component.','source-boundary'],['Native source inventory exceeds its bound.','input-bound'],['Too many native source entries.','input-bound'],['Empty or oversized notice.','input-bound'],['Notice inputs exceed the review bound.','input-bound']]); +export function noticeInputStep(operation,component,run,id){ + assert.ok(operations.has(operation)&&components.has(component)); + try{return run();}catch(thrown){ + const error=thrown instanceof Error?thrown:new Error('Notice input failed.'); + if(!contexts.has(error)){ + const category=error.code==='ENOENT'?'missing-input':['EACCES','EPERM'].includes(error.code)?'unreadable-input':error instanceof SyntaxError?'invalid-format':error.code==='ERR_ASSERTION'?(assertionCategories.get(error.message)??'invalid-input'):Number.isInteger(error.status)?'subprocess-failed':'unsupported-input'; + contexts.set(error,{operation,component,errorCategory:category,...(id?{componentSHA256:digest(id)}:{})}); + } + throw error; + } +} +export function noticeFailureContext(error){const value=contexts.get(error);return value?{...value}:undefined;} export function inspectNoticeArchive(path,payloadPath,bundlePath){ return JSON.parse(execFileSync('python3',[archiveReader.pathname,path,...(payloadPath?[payloadPath]:[]),...(bundlePath?[bundlePath]:[])],{encoding:'utf8',maxBuffer:32*1024*1024,timeout:60000,stdio:['ignore','pipe','pipe']})); } export function podNoticeInputs(root){ const lockPath=join(root,'ios/Podfile.lock'),support=join(root,'ios/Pods/Target Support Files'); if(!existsSync(lockPath)||!existsSync(support))return {components:[],unresolved:[{component:'native:ios',reason:'missing-resolved-pod-inputs'}],nativeFiles:[]}; - const lock=require('yaml').parse(noticeText(readFileSync(lockPath),16*1024*1024)),plist=require('@expo/plist').default,versions=new Map(),texts=new Map(),acknowledgements=[]; - for(const row of lock.PODS??[]){ + const lock=noticeInputStep('pod-lock','cocoapods',()=>require('yaml').parse(noticeText(readFileSync(lockPath),16*1024*1024))),plist=require('@expo/plist').default,versions=new Map(),texts=new Map(),acknowledgements=[]; + noticeInputStep('pod-lock-entries','cocoapods',()=>{for(const row of lock.PODS??[]){ const value=typeof row==='string'?row:Object.keys(row)[0],match=value.match(/^([^\s(]+) \(([^\s)]+)/);assert.ok(match,'Unsupported resolved pod entry.'); const name=match[1].split('/')[0],version=match[2];assert.ok(!versions.has(name)||versions.get(name)===version);versions.set(name,version); } - assert.ok(versions.size,'Missing resolved pods.'); - for(const target of readdirSync(support).sort()){ + assert.ok(versions.size,'Missing resolved pods.');}); + noticeInputStep('pod-acknowledgements','cocoapods',()=>{for(const target of readdirSync(support).sort()){ const path=join(support,target,target+'-acknowledgements.plist');if(!existsSync(path))continue; const bytes=readFileSync(path);acknowledgements.push({source:relative(root,path).split(sep).join('/'),sha256:digest(bytes)}); for(const row of plist.parse(noticeText(bytes,16*1024*1024)).PreferenceSpecifiers??[]){ @@ -28,13 +47,14 @@ export function podNoticeInputs(root){ texts.set(row.Title,list); } } + }); const unresolved=[],components=[]; for(const [name,version] of versions){ const notices=texts.get(name)??[],external=lock['EXTERNAL SOURCES']?.[name]?.[':path'];let scan={vendors:[],vendorNotices:{},nativeInputs:{},notices:[]}; const source=external?resolve(root,'ios',external):join(root,'ios/Pods',name); if(!existsSync(source))unresolved.push({component:`cocoapods:${name}@${version}`,reason:'missing-resolved-pod-source'}); - else scan=nativeSourceNotices(root,source,`cocoapods:${name}`); - components.push(nativeNoticeInput({id:`cocoapods:${name}@${version}`,name,version,ecosystem:'cocoapods',...scan,notices:[...notices,...scan.notices].sort((a,b)=>a.source.localeCompare(b.source))})); + else scan=noticeInputStep('pod-source-scan','cocoapods',()=>nativeSourceNotices(root,source,`cocoapods:${name}`),`cocoapods:${name}@${version}`); + components.push(noticeInputStep('pod-notices','cocoapods',()=>nativeNoticeInput({id:`cocoapods:${name}@${version}`,name,version,ecosystem:'cocoapods',...scan,notices:[...notices,...scan.notices].sort((a,b)=>a.source.localeCompare(b.source))}),`cocoapods:${name}@${version}`)); } return {components,unresolved,nativeFiles:[],inputHashes:{podLock:digest(readFileSync(lockPath)),podAcknowledgements:digest(JSON.stringify(acknowledgements))}}; } diff --git a/scripts/native-notice-inputs.test.mjs b/scripts/native-notice-inputs.test.mjs index 9b542617..ab8ec247 100644 --- a/scripts/native-notice-inputs.test.mjs +++ b/scripts/native-notice-inputs.test.mjs @@ -5,7 +5,7 @@ import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {createRequire} from 'node:module'; import test from 'node:test'; -import {podNoticeInputs,gradleNoticeInputs,unionNoticeInputs,inspectNoticeArchive} from './native-notice-inputs.mjs'; +import {podNoticeInputs,gradleNoticeInputs,unionNoticeInputs,inspectNoticeArchive,noticeInputStep,noticeFailureContext} from './native-notice-inputs.mjs'; import {assertPackagedNativeCoverage,assertSourceNoticePayload,digest,npmNoticeInputs,resolveNoticeCoverage} from './app-notices.mjs'; const require=createRequire(import.meta.url),plist=require('@expo/plist'); const mit=readFileSync(new URL('../node_modules/react-native/LICENSE',import.meta.url),'utf8'); @@ -136,3 +136,20 @@ test('the exact raw JSON resource has its own 16 MiB bound and is not counted ag execFileSync('python3',['-c',`import json,zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:z.writestr('assets/third_party_notices.json',json.dumps([{'body':'x'*(17*1024*1024)}]))`,path]); assert.throws(()=>inspectNoticeArchive(path,'assets/third_party_notices.json')); })); + + +test('finite native failure context preserves the innermost operation without raw error or component text',()=>fixture(({root,file})=>{ + file('ios/Podfile.lock','PODS:\n - QA (1)\n'); + file('ios/Pods/Target Support Files/Pods-QA/Pods-QA-acknowledgements.plist',plist.default.build({PreferenceSpecifiers:[]})); + file('ios/Pods/QA/LICENSE',Buffer.from([0xff])); + let failed; + try{noticeInputStep('native-inputs','ios',()=>podNoticeInputs(root));}catch(error){failed=error;} + assert.deepEqual(noticeFailureContext(failed),{operation:'pod-source-scan',component:'cocoapods',errorCategory:'invalid-utf8',componentSHA256:digest('cocoapods:QA@1')}); + for(const code of ['ENOENT','EACCES','ERR_ASSERTION',undefined]){ + try{noticeInputStep('pod-source-scan','cocoapods',()=>{throw Object.assign(new Error('constructor '+root+' SECRET_BODY ENV_SECRET'),{code,cause:{password:'SECRET_AUTH'}});},'PRIVATE_COMPONENT');}catch(error){ + const text=JSON.stringify(noticeFailureContext(error));for(const secret of [root,'SECRET','PRIVATE_COMPONENT','constructor','cause','password'])assert.ok(!text.includes(secret)); + assert.ok(['missing-input','unreadable-input','invalid-input','unsupported-input'].includes(noticeFailureContext(error).errorCategory)); + } + } + assert.equal(noticeFailureContext(Object.assign(new Error('private'),{native:{operation:'arbitrary'}})),undefined); +})); diff --git a/scripts/notices-runtime-root.test.groovy b/scripts/notices-runtime-root.test.groovy new file mode 100644 index 00000000..a6fd4543 --- /dev/null +++ b/scripts/notices-runtime-root.test.groovy @@ -0,0 +1,40 @@ +// Pure Groovy execution of the actual init script with Gradle callbacks stubbed. +// Uses the Groovy/Gradle API jars already included in a Gradle distribution; +// starts no Gradle build, dependency resolution, daemon or Android tooling. +import org.gradle.api.GradleException +import org.codehaus.groovy.control.CompilerConfiguration +import org.codehaus.groovy.control.customizers.ImportCustomizer +import java.nio.file.Files + +assert args.length == 1 +File source = new File(args[0]) +File fixture = Files.createTempDirectory('puptime-notice-gradle-root-').toFile() +try { + File main = new File(fixture, 'main/android'); main.mkdirs() + File included = new File(fixture, 'included/android'); included.mkdirs() + File alias = new File(fixture, 'canonical-alias') + Files.createSymbolicLink(alias.toPath(), main.toPath()) + def config = new CompilerConfiguration() + config.addCompilationCustomizers(new ImportCustomizer().addImports('org.gradle.api.GradleException')) + def execute = { File actual, Object requested, boolean hasApp -> + def registered = []; int lookups = 0 + def app = [tasks: [register: { name, body -> registered.add(name) }]] + def target = [projectDir: actual, findProject: { path -> assert path == ':app'; lookups++; hasApp ? app : null }] + def properties = requested == null ? [:] : [noticeMainProjectRoot: requested] + // Included builds need not repeat the main invocation's CLI properties. + def parent = actual == included ? [startParameter: [projectProperties: properties]] : null + def invocation = [parent: parent, rootProject: target, startParameter: [projectProperties: parent == null ? properties : [:]], projectsEvaluated: { body -> body.call() }] + new GroovyShell(this.class.classLoader, new Binding([gradle: invocation]), config).evaluate(source) + [registered: registered, lookups: lookups] + } + assert execute(included, main.path, false) == [registered: [], lookups: 0] + assert execute(main, main.path, true) == [registered: ['writeNoticeRuntimeInputs'], lookups: 1] + assert execute(main, alias.path, true) == [registered: ['writeNoticeRuntimeInputs'], lookups: 1] + for (requested in [main.path, null, 'relative/android', new File(fixture, 'absent').path]) { + try { execute(main, requested, false); assert false: 'Invalid main root/app must fail' } + catch (GradleException expected) { + assert expected.message == (requested == main.path ? 'Missing Android app project' : requested == null || requested == 'relative/android' ? 'Explicit absolute main Android root required' : 'Main Android root does not exist') + } + } + println 'Actual Gradle init script: included build skipped; main/canonical alias registered; missing app/root rejected (7 cases).' +} finally { fixture.deleteDir() } diff --git a/scripts/notices-runtime.init.gradle b/scripts/notices-runtime.init.gradle index c741e513..ae333454 100644 --- a/scripts/notices-runtime.init.gradle +++ b/scripts/notices-runtime.init.gradle @@ -5,6 +5,15 @@ import org.gradle.api.artifacts.component.ModuleComponentIdentifier import org.gradle.api.artifacts.component.ProjectComponentIdentifier gradle.projectsEvaluated { + // Init scripts also observe included plugin builds. Bind the intended main + // directory explicitly; a missing :app on THAT build still fails closed. + def invocation = gradle + while (invocation.parent != null) invocation = invocation.parent + def requestedRoot = invocation.startParameter.projectProperties['noticeMainProjectRoot'] + if (!requestedRoot || !new File(requestedRoot.toString()).isAbsolute()) throw new GradleException('Explicit absolute main Android root required') + def mainRoot = new File(requestedRoot.toString()).canonicalFile + if (!mainRoot.isDirectory()) throw new GradleException('Main Android root does not exist') + if (gradle.rootProject.projectDir.canonicalFile != mainRoot) return def app = gradle.rootProject.findProject(':app') if (app == null) throw new GradleException('Missing Android app project') app.tasks.register('writeNoticeRuntimeInputs') { From 899e89bda871242d5e0b2e92311b4ca7ec78e58a Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 06:34:29 +0800 Subject: [PATCH 15/37] Refine secret-safe source and setup notice failure receipts --- .github/workflows/ios-simulator.yml | 10 +++++ docs/app-notices.md | 10 ++++- scripts/app-notices.mjs | 23 +++++++----- scripts/app-notices.test.mjs | 54 ++++++++++++++++++++++++++- scripts/capture-app-notices.mjs | 16 ++++++-- scripts/native-notice-inputs.mjs | 7 ++-- scripts/native-notice-inputs.test.mjs | 3 ++ 7 files changed, 105 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ios-simulator.yml b/.github/workflows/ios-simulator.yml index 76d50592..c5a45d21 100644 --- a/.github/workflows/ios-simulator.yml +++ b/.github/workflows/ios-simulator.yml @@ -359,8 +359,18 @@ jobs: env: CI: '1' run: | + notice_setup_failure() { + notice_setup_status=$? + # Best effort only: preserve the original setup failure even if + # its fixed metadata cannot be written. Never inspect raw output. + node scripts/capture-app-notices.mjs setup-failure android --phase "$notice_setup_phase" || : + exit "$notice_setup_status" + } + trap notice_setup_failure ERR + notice_setup_phase=prebuild umask 077 npx expo prebuild --platform android --no-install + notice_setup_phase=gradle-resolution mkdir -p build/notices/android/private (cd android && ./gradlew -I ../scripts/notices-runtime.init.gradle \ :app:writeNoticeRuntimeInputs --no-daemon \ diff --git a/docs/app-notices.md b/docs/app-notices.md index ef41633b..9b33405f 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -61,13 +61,21 @@ node scripts/capture-app-notices.mjs verify android \ discovery inputs are not certified. It produces neither a payload nor a seal. Unexpected graph/source/archive errors still fail the command. `failure.json` may add a `native` object containing fixed operation/component/category enums and -an optional SHA-256 component identifier. It never includes raw errors, messages, +an optional SHA-256 component identifier. Source-graph failures additionally use +closed Error class/code enums and a SHA-256 of the supplied source string; no +source path or filename is included. It never includes raw errors, messages, causes, paths, notice bodies or environment values. The most specific failing Pod operation survives outer graph/union context. No bound is raised on failure. The Gradle init script requires an explicit absolute `noticeMainProjectRoot` and compares canonical project directories before registering its task. Other included builds are skipped; a missing `:app` on the selected root still fails. +Android discovery setup uses a best-effort ERR trap to call the existing CLI's +`setup-failure android --phase prebuild|gradle-resolution` mode. This writes only +`stage:setup` and fixed phase/`command-failed` fields to the same `failure.json`. +It neither interprets raw command output nor guesses the cause. The trap always +exits with the original nonzero setup status, including if metadata writing +fails. The fixed metadata upload remains separate from public notice bodies. This uses Gradle's public [root project](https://docs.gradle.org/current/javadoc/org/gradle/api/invocation/Gradle.html#getRootProject()) and [project directory](https://docs.gradle.org/current/javadoc/org/gradle/api/Project.html#getProjectDir()) APIs. The source-only root guard regression executes this actual init script through diff --git a/scripts/app-notices.mjs b/scripts/app-notices.mjs index 6ec2d8d5..c53ba638 100644 --- a/scripts/app-notices.mjs +++ b/scripts/app-notices.mjs @@ -164,18 +164,23 @@ export function addReviewedNoticeFiles(root,components,ledger,platform){ } const codeSource=path=>/\.(?:[cm]?js|jsx|tsx?|json)$/.test(path); -export function capturedSource(root,supplied,ledger={generatedSources:{}}){ - root=realpathSync(root);const modules=realpathSync(join(root,'node_modules')); - if(Object.hasOwn(ledger.generatedSources??{},supplied)){ +export function capturedSource(root,supplied,ledger={generatedSources:{}},step=(_operation,run)=>run()){ + root=step('source-root',()=>realpathSync(root));const modules=step('source-root',()=>realpathSync(join(root,'node_modules'))); + if(Object.hasOwn(ledger.generatedSources??{},supplied))return step('source-provider',()=>{ const value=ledger.generatedSources[supplied];assert.equal(supplied,'__prelude__','Only the known Metro virtual prelude has a reviewed provider.');assert.equal(value.provider,'node_modules/metro/src/lib/getPreludeCode.js'); const provider=realpathSync(join(root,value.provider));assert.ok(inside(modules,provider));assert.equal(digest(readFileSync(provider)),value.sha256); return {path:supplied,generated:true,provider:value.provider,sha256:value.sha256,component:npmNoticeInputs(root,[provider])[0].id}; - } - assert.ok(!/^[a-z]+:\/\//i.test(supplied)||supplied.startsWith('file://'),'Remote source-map paths are unsupported.'); - const path=realpathSync(supplied.startsWith('file://')?fileURLToPath(supplied):resolve(root,supplied)); - const installed=inside(modules,path);assert.ok(installed||inside(root,path),'App source resolved from another checkout.'); - const name=installed?'node_modules/'+safeRelative(relative(modules,path)):safeRelative(relative(root,path)); - return {path:name,sha256:digest(readFileSync(path)),code:codeSource(name),...(installed?{component:npmNoticeInputs(root,[path])[0].id}:{app:true})}; + }); + const path=step('source-path',()=>{ + assert.ok(!/^[a-z]+:\/\//i.test(supplied)||supplied.startsWith('file://'),'Remote source-map paths are unsupported.'); + return realpathSync(supplied.startsWith('file://')?fileURLToPath(supplied):resolve(root,supplied)); + }); + const installed=inside(modules,path),name=step('source-boundary',()=>{ + assert.ok(installed||inside(root,path),'App source resolved from another checkout.'); + return installed?'node_modules/'+safeRelative(relative(modules,path)):safeRelative(relative(root,path)); + }); + const sha256=step('source-bytes',()=>digest(readFileSync(path))); + return {path:name,sha256,code:codeSource(name),...(installed?{component:step('source-npm',()=>npmNoticeInputs(root,[path])[0].id)}:{app:true})}; } export function assertCapturedSources(root,sources,readCommitted){ assert.ok(sources.length);assert.equal(new Set(sources.map(value=>value.path)).size,sources.length); diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs index fe5f35e2..1657f7fc 100644 --- a/scripts/app-notices.test.mjs +++ b/scripts/app-notices.test.mjs @@ -1,10 +1,11 @@ import assert from 'node:assert/strict'; import {execFileSync,spawnSync} from 'node:child_process'; -import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,rmSync} from 'node:fs'; +import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,rmSync,realpathSync,chmodSync,symlinkSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {fileURLToPath} from 'node:url'; import test from 'node:test'; +import {parse} from 'yaml'; import {npmNoticeInputs,nativeNoticeInput,resolveNoticeCoverage,assertSourceNoticePayload,publicNoticeReport,assertFinalNoticeEvidence,capturedSource,assertCapturedSources,assertFinalSourceMap,assertPackagedNativeCoverage,digest,unreviewedOSSNoticeInputs} from './app-notices.mjs'; import {captureResolvedInputs,createNoticeSeal} from './capture-app-notices.mjs'; @@ -195,3 +196,54 @@ test('actual failure CLI retains only finite Pod context and no private path, bo const output=JSON.stringify(failure)+result.stdout+result.stderr; for(const secret of [root,'PRIVATE_NOTICE_BODY','SECRET_ENV_VALUE','stack','cause','at podNoticeInputs'])assert.ok(!output.includes(secret)); })); + + +test('actual pinned Metro path serializer returns flat strings including the explicit virtual prelude',async()=>{ + const {createRequire}=await import('node:module'),require=createRequire(import.meta.url); + const metro=join(require.resolve('metro/package.json'),'../src/DeltaBundler/Serializers/getAllFiles.js'); + const getAllFiles=require(metro).default; + const path=new URL('../node_modules/react-native/index.js',import.meta.url).pathname; + const result=await getAllFiles([{path:'__prelude__'}],{dependencies:new Map([[path,{path,output:[{type:'js/module',data:{code:'',lineCount:0,map:[]}}]}]])},{platform:'ios',processModuleFilter:()=>true}); + assert.deepEqual(result,['__prelude__',path]);assert.ok(result.every(value=>typeof value==='string')); +}); + +test('actual graph failure CLI emits the precise source boundary and known class/code plus hash only',()=>fixture(({root,file})=>{ + const git=(...args)=>execFileSync('git',['-c','core.hooksPath=/dev/null','-c','commit.gpgsign=false',...args],{cwd:root,stdio:'pipe'}); + file('.gitignore','build/\nnode_modules/\n');file('package-lock.json',{packages:{}});file('node_modules/.keep',''); + file('scripts/app-notice-provenance.json',{schema:1,components:[]});file('scripts/font-icon-provenance.json',fonts); + file('src/PRIVATE_SOURCE/.keep',''); + git('init','--quiet');git('add','.');git('-c','user.name=Notice test','-c','user.email=notice-test@example.invalid','commit','--quiet','-m','Synthetic source boundary'); + const sourceCommit=git('rev-parse','HEAD').toString().trim(); + file('build/notices/ios/seal.json',{sourceCommit,platform:'ios',sources:[{path:'src/PRIVATE_SOURCE'}]}); + const cli=spawnSync(process.execPath,[fileURLToPath(new URL('./capture-app-notices.mjs',import.meta.url)),'verify','ios','--archive',join(root,'build/unused.ipa')],{cwd:root,encoding:'utf8',timeout:30000,env:{...process.env,PRIVATE_NOTICE_ENV:'SECRET_ENV'}}); + assert.equal(cli.status,1); + const failure=JSON.parse(readFileSync(join(root,'build/notices/ios/failure.json'))); + assert.deepEqual(failure.native,{operation:'source-bytes',component:'metro',errorCategory:'unsupported-input',errorClass:'Error',errorCode:'EISDIR',sourceSHA256:digest(join(realpathSync(root),'src/PRIVATE_SOURCE'))}); + const output=JSON.stringify(failure)+cli.stdout+cli.stderr; + for(const secret of [root,'PRIVATE_SOURCE','SECRET_ENV','stack','cause','errno','syscall'])assert.ok(!output.includes(secret)); +})); + + +test('actual discovery setup trap keeps original failures and writes finite phases, including metadata-writer failure',()=>fixture(({root,file})=>{ + const git=(...args)=>execFileSync('git',['-c','core.hooksPath=/dev/null','-c','commit.gpgsign=false',...args],{cwd:root,stdio:'pipe'}); + file('fixture.txt','synthetic source');git('init','--quiet');git('add','.');git('-c','user.name=Notice test','-c','user.email=notice-test@example.invalid','commit','--quiet','-m','Synthetic setup receipt'); + const sourceCommit=git('rev-parse','HEAD').toString().trim(); + const cli=fileURLToPath(new URL('./capture-app-notices.mjs',import.meta.url));mkdirSync(join(root,'scripts'));symlinkSync(cli,join(root,'scripts/capture-app-notices.mjs')); + const npx=file('stubs/npx','#!/bin/sh\nprintf "%s\\n" prebuild >> "$NOTICE_TEST_CALLS"\nexit "$NOTICE_TEST_PREBUILD_STATUS"\n'); + const gradle=file('android/gradlew','#!/bin/sh\nprintf "%s\\n" gradle-resolution >> "$NOTICE_TEST_CALLS"\nexit "$NOTICE_TEST_GRADLE_STATUS"\n');chmodSync(npx,0o700);chmodSync(gradle,0o700); + const workflow=parse(readFileSync(new URL('../.github/workflows/ios-simulator.yml',import.meta.url),'utf8')); + const shell=workflow.jobs['notice-discovery'].steps.find(value=>value.name==='Prepare Android dependency inputs').run; + const calls=join(root,'calls.txt'),failurePath=join(root,'build/notices/android/failure.json'); + for(const value of [{pre:17,gradle:0,phase:'prebuild'},{pre:0,gradle:23,phase:'gradle-resolution'},{pre:0,gradle:0},{pre:17,gradle:0,writerFailure:true}]){ + rmSync(join(root,'build'),{recursive:true,force:true});rmSync(calls,{force:true});if(value.writerFailure)file('build','force receipt writer failure'); + const result=spawnSync('bash',['-e','-o','pipefail','-c',shell],{cwd:root,encoding:'utf8',timeout:30000,env:{...process.env,PATH:join(root,'stubs')+':'+process.env.PATH,NOTICE_TEST_CALLS:calls,NOTICE_TEST_PREBUILD_STATUS:String(value.pre),NOTICE_TEST_GRADLE_STATUS:String(value.gradle),PRIVATE_SETUP_ENV:'SECRET_SETUP_ENV'}}); + assert.equal(result.status,value.pre||value.gradle); + assert.equal(readFileSync(calls,'utf8'),value.pre?'prebuild\n':'prebuild\ngradle-resolution\n'); + if(value.phase){ + const failure=JSON.parse(readFileSync(failurePath));assert.deepEqual(failure,{schema:1,status:'INCOMPLETE',complete:false,sourceCommit,platform:'android',stage:'setup',setup:{phase:value.phase,errorCategory:'command-failed'}}); + const output=JSON.stringify(failure)+result.stdout+result.stderr;for(const secret of [root,'SECRET_SETUP_ENV','stack','cause'])assert.ok(!output.includes(secret)); + }else assert.equal(spawnSync('test',['-f',failurePath]).status,1); + } + rmSync(join(root,'build'),{recursive:true,force:true}); + const invalid=spawnSync(process.execPath,[cli,'setup-failure','android','--phase','PRIVATE_UNKNOWN_PHASE'],{cwd:root,encoding:'utf8',timeout:30000});assert.equal(invalid.status,1);assert.ok(!invalid.stderr.includes('PRIVATE_UNKNOWN_PHASE')); +})); diff --git a/scripts/capture-app-notices.mjs b/scripts/capture-app-notices.mjs index 7a4e14b5..94a90680 100644 --- a/scripts/capture-app-notices.mjs +++ b/scripts/capture-app-notices.mjs @@ -24,8 +24,10 @@ export function captureResolvedInputs(root,platform,paths,gradleInput){ const [ledger,fonts]=noticeInputStep('review-inputs','review',()=>[readJSON(join(root,ledgerPath)),readJSON(join(root,fontPath))]),unresolved=[],sources=[],physical=[]; noticeInputStep('source-graph','metro',()=>{for(const path of new Set(paths)){ if(path==='__prelude__'&&!Object.hasOwn(ledger.generatedSources??{},path)){unresolved.push({component:'metro:__prelude__',reason:'unreviewed-generated-source'});continue;} - const value=capturedSource(root,path,ledger);sources.push(value); - physical.push(join(root,value.generated?value.provider:value.path)); + noticeInputStep('source-graph','metro',()=>{ + const value=capturedSource(root,path,ledger,(operation,run)=>noticeInputStep(operation,'metro',run,undefined,path));sources.push(value); + physical.push(join(root,value.generated?value.provider:value.path)); + },undefined,path); } }); const native=noticeInputStep('native-inputs',platform,()=>nativeInputs(root,platform,gradleInput)); @@ -111,10 +113,16 @@ function finalNoticeProof(root,platform,seal,capture,payload,options){ } export async function main(args=process.argv.slice(2)){ - const [mode,platform,...tail]=args;assert.ok(['collect','seal','verify'].includes(mode)&&['ios','android'].includes(platform),'Use collect|seal|verify ios|android.'); + const [mode,platform,...tail]=args;assert.ok(['collect','seal','verify','setup-failure'].includes(mode)&&['ios','android'].includes(platform),'Use collect|seal|verify ios|android, or setup-failure android.'); const options={};assert.equal(tail.length%2,0); - for(let i=0;i{throw Object.assign(new TypeError('PRIVATE_MESSAGE'),{name:'PRIVATE_CLASS',code:'PRIVATE_CODE'});},undefined,'/PRIVATE_SOURCE');}catch(error){ + const value=noticeFailureContext(error);assert.deepEqual(value,{operation:'source-path',component:'metro',errorCategory:'unsupported-input',errorClass:'TypeError',errorCode:'other',sourceSHA256:digest('/PRIVATE_SOURCE')});assert.ok(!JSON.stringify(value).includes('PRIVATE')); + } assert.equal(noticeFailureContext(Object.assign(new Error('private'),{native:{operation:'arbitrary'}})),undefined); })); From 4fc7c7744e5f1f23096f5c41f6758d886d1b25de Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 06:46:14 +0800 Subject: [PATCH 16/37] Snapshot notice failure fields before finite projection --- scripts/native-notice-inputs.mjs | 6 ++++-- scripts/native-notice-inputs.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/scripts/native-notice-inputs.mjs b/scripts/native-notice-inputs.mjs index 8ea10e26..8e0bc92d 100644 --- a/scripts/native-notice-inputs.mjs +++ b/scripts/native-notice-inputs.mjs @@ -19,8 +19,10 @@ export function noticeInputStep(operation,component,run,id,source){ try{return run();}catch(thrown){ const error=thrown instanceof Error?thrown:new Error('Notice input failed.'); if(!contexts.has(error)){ - const category=error.code==='ENOENT'?'missing-input':['EACCES','EPERM'].includes(error.code)?'unreadable-input':error instanceof SyntaxError?'invalid-format':error.code==='ERR_ASSERTION'?(assertionCategories.get(error.message)??'invalid-input'):Number.isInteger(error.status)?'subprocess-failed':'unsupported-input'; - contexts.set(error,{operation,component,errorCategory:category,...(id?{componentSHA256:digest(id)}:{}),...(operation.startsWith('source-')?{errorClass:error instanceof TypeError?'TypeError':error instanceof SyntaxError?'SyntaxError':error instanceof RangeError?'RangeError':error.code==='ERR_ASSERTION'?'AssertionError':'Error',errorCode:sourceErrorCodes.has(error.code)?error.code:'other',...(typeof source==='string'?{sourceSHA256:digest(source)}:{})}:{})}); + let fields={};try{fields={code:error.code,status:error.status,message:error.message};}catch{} + const {code,status,message}=fields; + const category=code==='ENOENT'?'missing-input':['EACCES','EPERM'].includes(code)?'unreadable-input':error instanceof SyntaxError?'invalid-format':code==='ERR_ASSERTION'?(assertionCategories.get(message)??'invalid-input'):Number.isInteger(status)?'subprocess-failed':'unsupported-input'; + contexts.set(error,{operation,component,errorCategory:category,...(id?{componentSHA256:digest(id)}:{}),...(operation.startsWith('source-')?{errorClass:error instanceof TypeError?'TypeError':error instanceof SyntaxError?'SyntaxError':error instanceof ReferenceError?'ReferenceError':error instanceof RangeError?'RangeError':code==='ERR_ASSERTION'?'AssertionError':'Error',errorCode:sourceErrorCodes.has(code)?code:'other',...(typeof source==='string'?{sourceSHA256:digest(source)}:{})}:{})}); } throw error; } diff --git a/scripts/native-notice-inputs.test.mjs b/scripts/native-notice-inputs.test.mjs index 3d78b2d6..b69e0ca7 100644 --- a/scripts/native-notice-inputs.test.mjs +++ b/scripts/native-notice-inputs.test.mjs @@ -156,3 +156,23 @@ test('finite native failure context preserves the innermost operation without ra } assert.equal(noticeFailureContext(Object.assign(new Error('private'),{native:{operation:'arbitrary'}})),undefined); })); + + +test('source diagnostics keep ReferenceError finite and snapshot changing or throwing Error fields once',()=>{ + const capture=error=>{let caught;try{noticeInputStep('source-path','metro',()=>{throw error;},undefined,'/PRIVATE_SOURCE');}catch(value){caught=value;}assert.equal(caught,error);return noticeFailureContext(caught);}; + const reference=Object.assign(new ReferenceError('PRIVATE_MESSAGE'),{name:'PRIVATE_CLASS',code:'PRIVATE_CODE',cause:{token:'PRIVATE_TOKEN'}}); + const projected=capture(reference);assert.equal(projected.errorClass,'ReferenceError');assert.equal(projected.errorCode,'other');assert.ok(!JSON.stringify(projected).includes('PRIVATE')); + const reads={code:0,status:0,message:0},changing=new Error('PRIVATE_ORIGINAL'); + Object.defineProperties(changing,{ + code:{get(){return ++reads.code===1?'ERR_INVALID_ARG_TYPE':'PRIVATE_CHANGED_CODE';}}, + status:{get(){reads.status++;return undefined;}}, + message:{get(){reads.message++;return 'PRIVATE_CHANGED_MESSAGE';}}, + }); + const stable=capture(changing);assert.equal(stable.errorCode,'ERR_INVALID_ARG_TYPE');assert.deepEqual(reads,{code:1,status:1,message:1});assert.ok(!JSON.stringify(stable).includes('PRIVATE')); + for(const throwing of ['code','status','message']){ + const counts={code:0,status:0,message:0},original=new Error('PRIVATE_ORIGINAL'); + for(const field of Object.keys(counts))Object.defineProperty(original,field,{get(){counts[field]++;if(field===throwing)throw new ReferenceError('PRIVATE_GETTER_FAILURE');return field==='code'?'ERR_ASSERTION':field==='status'?23:'PRIVATE_MESSAGE';}}); + const value=capture(original);assert.equal(value.errorCode,'other');assert.equal(value.errorCategory,'unsupported-input');assert.ok(!JSON.stringify(value).includes('PRIVATE')); + assert.equal(counts[throwing],1);assert.ok(Object.values(counts).every(count=>count<=1)); + } +}); From fe91bcd114adc5616040fdaa031795fa1b352c43 Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:05:55 +0800 Subject: [PATCH 17/37] Bind observed Expo virtual source to its pinned provider --- docs/app-notices.md | 9 +++++++-- scripts/app-notice-provenance.json | 8 +++++++- scripts/app-notices.mjs | 8 ++++++-- scripts/app-notices.test.mjs | 24 ++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index 9b33405f..65e536cd 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -154,8 +154,13 @@ When an AAR/JAR omits full text, an explicitly reviewed `additionalNoticeFiles` entry may point at an actual pinned upstream copy under `assets/notices/sources/` with `file`, `sha256`, `upstream`. It supplements discovered notices; it cannot replace a missing NOTICE with a package label or an assumed parent licence. -Generated Metro sources permit only the known `__prelude__`, with its exact -installed Metro generator path/hash. Final native files not identified from resolved AARs require an exact +Generated Metro sources permit only the known `__prelude__` and Expo CLI’s exact +NUL-prefixed `polyfill:external-require` ID, with their exact +installed generator path/hash. The Expo provider additionally pins its exact +installed package path, name and version against package-lock and the committed +component ID. This is a technical generated-source mapping, not reviewed notice +coverage; the component ledger remains empty. Other virtual IDs are not accepted +by a NUL/path wildcard. Final native files not identified from resolved AARs require an exact platform/path `packagedNative` mapping with covered `components` and a pinned `review` reference. These mappings are intentionally empty until discovery. diff --git a/scripts/app-notice-provenance.json b/scripts/app-notice-provenance.json index 75ae5637..73c0bc15 100644 --- a/scripts/app-notice-provenance.json +++ b/scripts/app-notice-provenance.json @@ -2,7 +2,13 @@ "schema": 1, "scope": "INCOMPLETE preparation only. Collect actual Metro/native inputs and commit reviewed platform-union text and mappings before sealing. Empty mappings deliberately fail strict verification; discovery never writes a release payload.", "components": [], - "generatedSources": {}, + "generatedSources": { + "\u0000polyfill:external-require": { + "provider": "node_modules/expo/node_modules/@expo/cli/build/src/start/server/metro/withMetroMultiPlatform.js", + "sha256": "50012292a38cb679ca3f692c5a462de57bebe0f69d5c862e937141a3fc54343f", + "component": "npm:@expo/cli@57.0.26" + } + }, "packagedNative": {"ios": {}, "android": {}}, "knownLicenseSources": [ {"component":"npm:react-native@0.86.3","ecosystem":"npm","version":"0.86.3","public":true,"source":"node_modules/react-native/LICENSE","sha256":"da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93","upstream":"https://github.com/react/react-native/blob/v0.86.3/LICENSE"}, diff --git a/scripts/app-notices.mjs b/scripts/app-notices.mjs index c53ba638..118c6aaf 100644 --- a/scripts/app-notices.mjs +++ b/scripts/app-notices.mjs @@ -164,12 +164,16 @@ export function addReviewedNoticeFiles(root,components,ledger,platform){ } const codeSource=path=>/\.(?:[cm]?js|jsx|tsx?|json)$/.test(path); +const expoExternalRequire='\0polyfill:external-require'; +const generatedProviders=new Map([['__prelude__','node_modules/metro/src/lib/getPreludeCode.js'],[expoExternalRequire,'node_modules/expo/node_modules/@expo/cli/build/src/start/server/metro/withMetroMultiPlatform.js']]); export function capturedSource(root,supplied,ledger={generatedSources:{}},step=(_operation,run)=>run()){ root=step('source-root',()=>realpathSync(root));const modules=step('source-root',()=>realpathSync(join(root,'node_modules'))); if(Object.hasOwn(ledger.generatedSources??{},supplied))return step('source-provider',()=>{ - const value=ledger.generatedSources[supplied];assert.equal(supplied,'__prelude__','Only the known Metro virtual prelude has a reviewed provider.');assert.equal(value.provider,'node_modules/metro/src/lib/getPreludeCode.js'); + const value=ledger.generatedSources[supplied],expected=generatedProviders.get(supplied);assert.ok(expected,'Unknown virtual provider.');assert.equal(value.provider,expected); const provider=realpathSync(join(root,value.provider));assert.ok(inside(modules,provider));assert.equal(digest(readFileSync(provider)),value.sha256); - return {path:supplied,generated:true,provider:value.provider,sha256:value.sha256,component:npmNoticeInputs(root,[provider])[0].id}; + const owner=npmNoticeInputs(root,[provider])[0]; + if(supplied===expoExternalRequire){assert.equal(owner.name,'@expo/cli');assert.equal(owner.packagePath,'node_modules/expo/node_modules/@expo/cli');assert.equal(owner.id,value.component,'Virtual provider owner/version changed.');} + return {path:supplied,generated:true,provider:value.provider,sha256:value.sha256,component:owner.id}; }); const path=step('source-path',()=>{ assert.ok(!/^[a-z]+:\/\//i.test(supplied)||supplied.startsWith('file://'),'Remote source-map paths are unsupported.'); diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs index 1657f7fc..6e4742d0 100644 --- a/scripts/app-notices.test.mjs +++ b/scripts/app-notices.test.mjs @@ -247,3 +247,27 @@ test('actual discovery setup trap keeps original failures and writes finite phas rmSync(join(root,'build'),{recursive:true,force:true}); const invalid=spawnSync(process.execPath,[cli,'setup-failure','android','--phase','PRIVATE_UNKNOWN_PHASE'],{cwd:root,encoding:'utf8',timeout:30000});assert.equal(invalid.status,1);assert.ok(!invalid.stderr.includes('PRIVATE_UNKNOWN_PHASE')); })); + +test('the exact observed Expo external-require virtual ID resolves only to its pinned installed provider',()=>{ + const id='\0polyfill:external-require';assert.equal(digest(id),'f32cbb35a430d4c6ee0f555a934acc8f6a83aff8e2b19c414f6d756479b8f03e'); + const provenance=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))); + const result=capturedSource(process.cwd(),id,provenance); + assert.deepEqual(result,{path:id,generated:true,provider:'node_modules/expo/node_modules/@expo/cli/build/src/start/server/metro/withMetroMultiPlatform.js',sha256:'50012292a38cb679ca3f692c5a462de57bebe0f69d5c862e937141a3fc54343f',component:'npm:@expo/cli@57.0.26'}); + assert.deepEqual(provenance.components,[],'A technical provider mapping is not reviewed app-library notice coverage.'); +}); + +test('virtual provider mapping rejects modified bytes, wrong path/owner/version and every unknown virtual ID',()=>fixture(({root,file})=>{ + const id='\0polyfill:external-require',provider='node_modules/expo/node_modules/@expo/cli/build/src/start/server/metro/withMetroMultiPlatform.js',packagePath='node_modules/expo/node_modules/@expo/cli',content='export const fixture=1;'; + const pkg={name:'@expo/cli',version:'1.2.3'},lock={packages:{[packagePath]:{version:'1.2.3'}}};file('package-lock.json',lock);file(packagePath+'/package.json',pkg);file(packagePath+'/LICENSE',mit);file(provider,content); + const record={provider,sha256:digest(content),component:'npm:@expo/cli@1.2.3'},provenance={generatedSources:{[id]:record}}; + assert.equal(capturedSource(root,id,provenance).component,record.component); + file(provider,content+'modified');assert.throws(()=>capturedSource(root,id,provenance));file(provider,content); + assert.throws(()=>capturedSource(root,id,{generatedSources:{[id]:{...record,provider:'node_modules/metro/src/lib/getPreludeCode.js'}}})); + file(packagePath+'/package.json',{...pkg,version:'9.9.9'});assert.throws(()=>capturedSource(root,id,provenance)); + file('package-lock.json',{packages:{[packagePath]:{version:'9.9.9'}}});assert.throws(()=>capturedSource(root,id,provenance),'Updating installed and locked version cannot evade the committed component pin.'); + file('package-lock.json',lock);file(packagePath+'/package.json',{...pkg,name:'private-other'});assert.throws(()=>capturedSource(root,id,provenance));file(packagePath+'/package.json',pkg); + for(const unknown of ['\0polyfill:unknown','\0polyfill:environment-variables','\0polyfill:assets-registry']){ + assert.throws(()=>capturedSource(root,unknown,provenance)); + assert.throws(()=>capturedSource(root,unknown,{generatedSources:{[unknown]:record}}),'An arbitrary virtual ID cannot reuse an allowed provider.'); + } +})); From 8b4a86eb94a4f2a782bc58a93f368b936473cbdd Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:11:11 +0800 Subject: [PATCH 18/37] Retain finite Gradle collector failure boundaries --- docs/app-notices.md | 12 +++++- scripts/app-notices.test.mjs | 15 ++++++++ scripts/capture-app-notices.mjs | 15 +++++++- scripts/notices-runtime-root.test.groovy | 34 +++++++++++++++-- scripts/notices-runtime.init.gradle | 48 +++++++++++++++++++----- 5 files changed, 109 insertions(+), 15 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index 65e536cd..5a237774 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -76,6 +76,16 @@ Android discovery setup uses a best-effort ERR trap to call the existing CLI's It neither interprets raw command output nor guesses the cause. The trap always exits with the original nonzero setup status, including if metadata writing fails. The fixed metadata upload remains separate from public notice bodies. +If reached, the existing Gradle collector adds only a fixed root-binding, +task-registration, runtime-resolution or artifact-collection phase and a closed +public/base Throwable class. It reads no exception message or stack and always +rethrows the same Throwable; a failed/outside-workspace metadata write is ignored. +The shell writer retains this context only after exact schema, source and enum +validation, then reconstructs the finite report. Failures before these callbacks +or during other Gradle/plugin configuration still have only the generic setup +phase; absence of collector context is not evidence the collector caused them. +No listener, service, configuration-cache setting, resolution or task gate is added. + This uses Gradle's public [root project](https://docs.gradle.org/current/javadoc/org/gradle/api/invocation/Gradle.html#getRootProject()) and [project directory](https://docs.gradle.org/current/javadoc/org/gradle/api/Project.html#getProjectDir()) APIs. The source-only root guard regression executes this actual init script through @@ -162,7 +172,7 @@ component ID. This is a technical generated-source mapping, not reviewed notice coverage; the component ledger remains empty. Other virtual IDs are not accepted by a NUL/path wildcard. Final native files not identified from resolved AARs require an exact platform/path `packagedNative` mapping with covered `components` and a pinned -`review` reference. These mappings are intentionally empty until discovery. +`review` reference. Packaged-native mappings remain empty until reviewed discovery. Metro inputs are selected files, not every declared or installed package. Nested packages have their own licence boundary. CocoaPods inputs include resolved pod diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs index 6e4742d0..1e3f6e2b 100644 --- a/scripts/app-notices.test.mjs +++ b/scripts/app-notices.test.mjs @@ -271,3 +271,18 @@ test('virtual provider mapping rejects modified bytes, wrong path/owner/version assert.throws(()=>capturedSource(root,unknown,{generatedSources:{[unknown]:record}}),'An arbitrary virtual ID cannot reuse an allowed provider.'); } })); + +test('setup failure CLI retains only matching finite Gradle collector context and strips every untrusted record',()=>fixture(({root,file})=>{ + const git=(...args)=>execFileSync('git',['-c','core.hooksPath=/dev/null','-c','commit.gpgsign=false',...args],{cwd:root,stdio:'pipe'}); + file('fixture.txt','synthetic');git('init','--quiet');git('add','.');git('-c','user.name=Notice test','-c','user.email=notice-test@example.invalid','commit','--quiet','-m','Synthetic collector metadata'); + const sourceCommit=git('rev-parse','HEAD').toString().trim(),path='build/notices/android/failure.json'; + const generic={schema:1,status:'INCOMPLETE',complete:false,sourceCommit,platform:'android',stage:'setup',setup:{phase:'gradle-resolution',errorCategory:'command-failed'}}; + const known={...generic,setup:{...generic.setup,collector:{phase:'runtime-resolution',errorClass:'ResolveException'}}}; + const cli=phase=>spawnSync(process.execPath,[fileURLToPath(new URL('./capture-app-notices.mjs',import.meta.url)),'setup-failure','android','--phase',phase],{cwd:root,encoding:'utf8',timeout:30000}); + file(path,known);assert.equal(cli('gradle-resolution').status,0);assert.deepEqual(JSON.parse(readFileSync(join(root,path))),known); + for(const changed of [{...known,sourceCommit:'b'.repeat(40)},{...known,privateMessage:'PRIVATE_BODY'},{...known,setup:{...known.setup,collector:{phase:'PRIVATE_PHASE',errorClass:'ResolveException'}}},{...known,setup:{...known.setup,collector:{phase:'runtime-resolution',errorClass:'PRIVATE_CLASS'}}},'PRIVATE_BODY'.repeat(500)]){ + file(path,changed);const result=cli('gradle-resolution');assert.equal(result.status,0); + const value=JSON.parse(readFileSync(join(root,path)));assert.deepEqual(value,generic);assert.ok(!(JSON.stringify(value)+result.stdout+result.stderr).includes('PRIVATE')); + } + file(path,known);assert.equal(cli('prebuild').status,0);assert.deepEqual(JSON.parse(readFileSync(join(root,path))),{...generic,setup:{phase:'prebuild',errorCategory:'command-failed'}}); +})); diff --git a/scripts/capture-app-notices.mjs b/scripts/capture-app-notices.mjs index 94a90680..edebd1b4 100644 --- a/scripts/capture-app-notices.mjs +++ b/scripts/capture-app-notices.mjs @@ -112,6 +112,18 @@ function finalNoticeProof(root,platform,seal,capture,payload,options){ return {schema:1,scope:'final-artifact-verification',status:'READY',sourceCommit:seal.sourceCommit,platform,payloadSHA256:digest(payload),archiveSHA256:digest(readFileSync(options['--archive'])),bundleSHA256:archive.bundle.sha256,sourceMapSHA256:digest(readFileSync(options['--source-map'])),mapEvidence,native,inventory:{observedComponents,resolvedBuildOnly,independentFinalComponentInventory:false,postPackageResolvedInputReinspection:true,postBuildResolutionRegenerated:false},complete:true}; } +function retainedCollectorFailure(path,sourceCommit,phase){ + try{ + if(phase!=='gradle-resolution'||!existsSync(path)||statSync(path).size>4096)return; + const value=JSON.parse(noticeText(readFileSync(path),4096)),setup=value.setup,collector=setup?.collector; + assert.deepEqual(Object.keys(value).sort(),['schema','status','complete','sourceCommit','platform','stage','setup'].sort()); + assert.equal(value.schema,1);assert.equal(value.status,'INCOMPLETE');assert.equal(value.complete,false);assert.equal(value.sourceCommit,sourceCommit);assert.equal(value.platform,'android');assert.equal(value.stage,'setup'); + assert.deepEqual(Object.keys(setup).sort(),['phase','errorCategory','collector'].sort());assert.equal(setup.phase,phase);assert.equal(setup.errorCategory,'command-failed'); + assert.deepEqual(Object.keys(collector).sort(),['phase','errorClass'].sort());assert.ok(['root-binding','task-registration','runtime-resolution','artifact-collection'].includes(collector.phase));assert.ok(['ResolveException','GradleException','IOException','IllegalArgumentException','IllegalStateException','SecurityException','Error','Throwable'].includes(collector.errorClass)); + return {phase:collector.phase,errorClass:collector.errorClass}; + }catch{return;} +} + export async function main(args=process.argv.slice(2)){ const [mode,platform,...tail]=args;assert.ok(['collect','seal','verify','setup-failure'].includes(mode)&&['ios','android'].includes(platform),'Use collect|seal|verify ios|android, or setup-failure android.'); const options={};assert.equal(tail.length%2,0); @@ -119,7 +131,8 @@ export async function main(args=process.argv.slice(2)){ const root=realpathSync(process.cwd()),out=outputDirectory(root,platform),sourceCommit=git(root,'rev-parse','HEAD').toString().trim();let stage='metro'; if(mode==='setup-failure'){ assert.equal(platform,'android');assert.deepEqual(Object.keys(options),['--phase']);assert.ok(['prebuild','gradle-resolution'].includes(options['--phase'])); - write(join(out,'failure.json'),{schema:1,status:'INCOMPLETE',complete:false,sourceCommit,platform,stage:'setup',setup:{phase:options['--phase'],errorCategory:'command-failed'}}); + const collector=retainedCollectorFailure(join(out,'failure.json'),sourceCommit,options['--phase']); + write(join(out,'failure.json'),{schema:1,status:'INCOMPLETE',complete:false,sourceCommit,platform,stage:'setup',setup:{phase:options['--phase'],errorCategory:'command-failed',...(collector?{collector}:{})}}); console.error('Notices setup failed; finite metadata only, no collection or qualification.');return; } assert.ok(!Object.hasOwn(options,'--phase')); diff --git a/scripts/notices-runtime-root.test.groovy b/scripts/notices-runtime-root.test.groovy index a6fd4543..ec7dd7f4 100644 --- a/scripts/notices-runtime-root.test.groovy +++ b/scripts/notices-runtime-root.test.groovy @@ -5,6 +5,9 @@ import org.gradle.api.GradleException import org.codehaus.groovy.control.CompilerConfiguration import org.codehaus.groovy.control.customizers.ImportCustomizer import java.nio.file.Files +import groovy.json.JsonSlurper + +class PrivateNoticeFailure extends RuntimeException { String getMessage() { throw new IllegalStateException('PRIVATE_GETTER') } } assert args.length == 1 File source = new File(args[0]) @@ -16,14 +19,20 @@ try { Files.createSymbolicLink(alias.toPath(), main.toPath()) def config = new CompilerConfiguration() config.addCompilationCustomizers(new ImportCustomizer().addImports('org.gradle.api.GradleException')) - def execute = { File actual, Object requested, boolean hasApp -> + def execute = { File actual, Object requested, boolean hasApp, Map fault=[:] -> def registered = []; int lookups = 0 - def app = [tasks: [register: { name, body -> registered.add(name) }]] - def target = [projectDir: actual, findProject: { path -> assert path == ':app'; lookups++; hasApp ? app : null }] + def configuration = [resolvedConfiguration: [resolvedArtifacts: []], incoming: [resolutionResult: [allComponents: [iterator: { -> throw fault.failure }, size: { -> 1 }] as Collection]]] + def app = [findProperty: { name -> new File(main.parentFile, 'build/private-input.json').path }, configurations: [getByName: { name -> if (fault.phase == 'runtime-resolution') throw fault.failure; configuration }], tasks: [register: { name, body -> + if (fault.phase == 'task-registration') throw fault.failure + registered.add(name) + if (fault.runTask) { def task = new Expando(); task.doLast = { action -> action.call() }; body.rehydrate(task, body.owner, body.thisObject).call() } + }]] + def target = [projectDir: actual, findProject: { path -> assert path == ':app'; lookups++; if (fault.phase == 'root-binding') throw fault.failure; hasApp ? app : null }] def properties = requested == null ? [:] : [noticeMainProjectRoot: requested] // Included builds need not repeat the main invocation's CLI properties. def parent = actual == included ? [startParameter: [projectProperties: properties]] : null def invocation = [parent: parent, rootProject: target, startParameter: [projectProperties: parent == null ? properties : [:]], projectsEvaluated: { body -> body.call() }] + if (fault.diagnostics) invocation.providers = [environmentVariable: { name -> [orNull: name == 'GITHUB_WORKSPACE' ? main.parentFile.path : name == 'GITHUB_SHA' ? 'a' * 40 : null] }] new GroovyShell(this.class.classLoader, new Binding([gradle: invocation]), config).evaluate(source) [registered: registered, lookups: lookups] } @@ -36,5 +45,22 @@ try { assert expected.message == (requested == main.path ? 'Missing Android app project' : requested == null || requested == 'relative/android' ? 'Explicit absolute main Android root required' : 'Main Android root does not exist') } } - println 'Actual Gradle init script: included build skipped; main/canonical alias registered; missing app/root rejected (7 cases).' + File build = new File(main.parentFile, 'build'), report = new File(build, 'notices/android/failure.json') + def clear = { if (build.isDirectory()) build.deleteDir(); else build.delete() } + for (phase in ['root-binding', 'task-registration', 'runtime-resolution', 'artifact-collection']) { + clear(); def original = phase == 'runtime-resolution' ? new IllegalStateException('PRIVATE_MESSAGE') : new PrivateNoticeFailure(); Throwable caught + try { execute(main, main.path, true, [phase: phase, failure: original, diagnostics: true, runTask: true]) } catch (Throwable failure) { caught = failure } + assert caught.is(original) + def text = report.text, value = new JsonSlurper().parseText(text) + assert value == [schema: 1, status: 'INCOMPLETE', complete: false, sourceCommit: 'a' * 40, platform: 'android', stage: 'setup', setup: [phase: 'gradle-resolution', errorCategory: 'command-failed', collector: [phase: phase, errorClass: phase == 'runtime-resolution' ? 'IllegalStateException' : 'Throwable']]] + assert !text.contains('PRIVATE') && !text.contains(fixture.path) && !text.contains('stack') + } + for (blocked in ['file', 'symlink']) { + clear(); if (blocked == 'file') build.text = 'PRIVATE_BLOCKER'; else Files.createSymbolicLink(build.toPath(), new File(fixture, 'outside').tap { mkdirs() }.toPath()) + def original = new IllegalArgumentException('PRIVATE_ORIGINAL'); Throwable caught + try { execute(main, main.path, true, [phase: 'runtime-resolution', failure: original, diagnostics: true, runTask: true]) } catch (Throwable failure) { caught = failure } + assert caught.is(original) && !report.exists() + build.delete() + } + println 'Actual init source: 7 root guards +4 finite Throwable phases +2 failed/outside writers PASS; no Gradle build.' } finally { fixture.deleteDir() } diff --git a/scripts/notices-runtime.init.gradle b/scripts/notices-runtime.init.gradle index ae333454..35a196fb 100644 --- a/scripts/notices-runtime.init.gradle +++ b/scripts/notices-runtime.init.gradle @@ -4,24 +4,53 @@ import java.security.MessageDigest import org.gradle.api.artifacts.component.ModuleComponentIdentifier import org.gradle.api.artifacts.component.ProjectComponentIdentifier +// Diagnostic output is optional and finite. Never serialize Throwable text. +def recordNoticeFailure = { File mainRoot, String phase, Throwable failure -> + try { + def workspaceValue = gradle.providers.environmentVariable('GITHUB_WORKSPACE').orNull + def source = gradle.providers.environmentVariable('GITHUB_SHA').orNull + if (!workspaceValue || !(source ==~ /[a-f0-9]{40}/) || mainRoot == null) return + def workspace = new File(workspaceValue).canonicalFile + if (mainRoot != new File(workspace, 'android').canonicalFile) return + def output = new File(workspace, 'build/notices/android/failure.json') + if (output.canonicalFile != output.absoluteFile) return + def kind = failure instanceof org.gradle.api.artifacts.ResolveException ? 'ResolveException' : failure instanceof GradleException ? 'GradleException' : failure instanceof IOException ? 'IOException' : failure instanceof IllegalArgumentException ? 'IllegalArgumentException' : failure instanceof IllegalStateException ? 'IllegalStateException' : failure instanceof SecurityException ? 'SecurityException' : failure instanceof Error ? 'Error' : 'Throwable' + output.parentFile.mkdirs() + output.text = JsonOutput.toJson([schema: 1, status: 'INCOMPLETE', complete: false, sourceCommit: source, platform: 'android', stage: 'setup', setup: [phase: 'gradle-resolution', errorCategory: 'command-failed', collector: [phase: phase, errorClass: kind]]]) + } catch (Throwable ignored) { } +} + gradle.projectsEvaluated { + File mainRoot + def reported = Collections.newSetFromMap(new IdentityHashMap()) + def observed = { String phase, Closure action -> + try { return action.call() } catch (Throwable failure) { if (reported.add(failure)) recordNoticeFailure(mainRoot, phase, failure); throw failure } + } + def app = observed('root-binding') { // Init scripts also observe included plugin builds. Bind the intended main // directory explicitly; a missing :app on THAT build still fails closed. def invocation = gradle while (invocation.parent != null) invocation = invocation.parent def requestedRoot = invocation.startParameter.projectProperties['noticeMainProjectRoot'] if (!requestedRoot || !new File(requestedRoot.toString()).isAbsolute()) throw new GradleException('Explicit absolute main Android root required') - def mainRoot = new File(requestedRoot.toString()).canonicalFile + mainRoot = new File(requestedRoot.toString()).canonicalFile if (!mainRoot.isDirectory()) throw new GradleException('Main Android root does not exist') - if (gradle.rootProject.projectDir.canonicalFile != mainRoot) return - def app = gradle.rootProject.findProject(':app') - if (app == null) throw new GradleException('Missing Android app project') - app.tasks.register('writeNoticeRuntimeInputs') { + if (gradle.rootProject.projectDir.canonicalFile != mainRoot) return null + def selectedApp = gradle.rootProject.findProject(':app') + if (selectedApp == null) throw new GradleException('Missing Android app project') + return selectedApp + } + if (app == null) return + observed('task-registration') { app.tasks.register('writeNoticeRuntimeInputs') { doLast { - def requested = app.findProperty('noticeInputsOutput') + def requested, configuration, artifacts + observed('runtime-resolution') { + requested = app.findProperty('noticeInputsOutput') if (!requested) throw new GradleException('Explicit private notice output required') - def configuration = app.configurations.getByName('releaseRuntimeClasspath') - def artifacts = configuration.resolvedConfiguration.resolvedArtifacts + configuration = app.configurations.getByName('releaseRuntimeClasspath') + artifacts = configuration.resolvedConfiguration.resolvedArtifacts + } + observed('artifact-collection') { def metadataOnly = [] def components = configuration.incoming.resolutionResult.allComponents.collect { component -> def id = component.id @@ -53,6 +82,7 @@ gradle.projectsEvaluated { def output = new File(requested.toString()) output.parentFile.mkdirs() output.text = JsonOutput.toJson([schema: 1, configuration: 'releaseRuntimeClasspath', components: components, metadataOnly: metadataOnly]) + } } - } + } } } From 28690aa10e1c1363e4e9b5bea5e4c61e9b689f3e Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:17:42 +0800 Subject: [PATCH 19/37] Cover required pinned Expo virtual providers explicitly Prove the native polyfill and app font asset routes without Metro; use the pinned public Project provider API for finite Gradle diagnostics. --- docs/app-notices.md | 10 +++++-- scripts/app-notice-provenance.json | 10 +++++++ scripts/app-notices.mjs | 6 ++-- scripts/app-notices.test.mjs | 36 +++++++++++++++++++++--- scripts/notices-runtime-root.test.groovy | 2 +- scripts/notices-runtime.init.gradle | 4 +-- 6 files changed, 56 insertions(+), 12 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index 5a237774..6c105ac8 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -165,10 +165,16 @@ entry may point at an actual pinned upstream copy under `assets/notices/sources/ with `file`, `sha256`, `upstream`. It supplements discovered notices; it cannot replace a missing NOTICE with a package label or an assumed parent licence. Generated Metro sources permit only the known `__prelude__` and Expo CLI’s exact -NUL-prefixed `polyfill:external-require` ID, with their exact +NUL-prefixed `polyfill:external-require`, `polyfill:environment-variables` and +`polyfill:assets-registry` IDs, with their exact installed generator path/hash. The Expo provider additionally pins its exact installed package path, name and version against package-lock and the committed -component ID. This is a technical generated-source mapping, not reviewed notice +component ID. The same pinned native polyfill function registers `external-require` and +`environment-variables`; +the app imports the MaterialCommunityIcons TTF, whose generated asset registry +require is intercepted by the pinned resolver into the third. Source-only tests +exercise those actual provider functions in memory without Metro or dynamic +environment values. These are technical generated-source mappings, not reviewed notice coverage; the component ledger remains empty. Other virtual IDs are not accepted by a NUL/path wildcard. Final native files not identified from resolved AARs require an exact platform/path `packagedNative` mapping with covered `components` and a pinned diff --git a/scripts/app-notice-provenance.json b/scripts/app-notice-provenance.json index 73c0bc15..f07b86a2 100644 --- a/scripts/app-notice-provenance.json +++ b/scripts/app-notice-provenance.json @@ -7,6 +7,16 @@ "provider": "node_modules/expo/node_modules/@expo/cli/build/src/start/server/metro/withMetroMultiPlatform.js", "sha256": "50012292a38cb679ca3f692c5a462de57bebe0f69d5c862e937141a3fc54343f", "component": "npm:@expo/cli@57.0.26" + }, + "\u0000polyfill:environment-variables": { + "provider": "node_modules/expo/node_modules/@expo/cli/build/src/start/server/metro/withMetroMultiPlatform.js", + "sha256": "50012292a38cb679ca3f692c5a462de57bebe0f69d5c862e937141a3fc54343f", + "component": "npm:@expo/cli@57.0.26" + }, + "\u0000polyfill:assets-registry": { + "provider": "node_modules/expo/node_modules/@expo/cli/build/src/start/server/metro/withMetroMultiPlatform.js", + "sha256": "50012292a38cb679ca3f692c5a462de57bebe0f69d5c862e937141a3fc54343f", + "component": "npm:@expo/cli@57.0.26" } }, "packagedNative": {"ios": {}, "android": {}}, diff --git a/scripts/app-notices.mjs b/scripts/app-notices.mjs index 118c6aaf..e4170dc3 100644 --- a/scripts/app-notices.mjs +++ b/scripts/app-notices.mjs @@ -164,15 +164,15 @@ export function addReviewedNoticeFiles(root,components,ledger,platform){ } const codeSource=path=>/\.(?:[cm]?js|jsx|tsx?|json)$/.test(path); -const expoExternalRequire='\0polyfill:external-require'; -const generatedProviders=new Map([['__prelude__','node_modules/metro/src/lib/getPreludeCode.js'],[expoExternalRequire,'node_modules/expo/node_modules/@expo/cli/build/src/start/server/metro/withMetroMultiPlatform.js']]); +const expoGeneratedSources=new Set(['\0polyfill:external-require','\0polyfill:environment-variables','\0polyfill:assets-registry']); +const generatedProviders=new Map([['__prelude__','node_modules/metro/src/lib/getPreludeCode.js'],...[...expoGeneratedSources].map(id=>[id,'node_modules/expo/node_modules/@expo/cli/build/src/start/server/metro/withMetroMultiPlatform.js'])]); export function capturedSource(root,supplied,ledger={generatedSources:{}},step=(_operation,run)=>run()){ root=step('source-root',()=>realpathSync(root));const modules=step('source-root',()=>realpathSync(join(root,'node_modules'))); if(Object.hasOwn(ledger.generatedSources??{},supplied))return step('source-provider',()=>{ const value=ledger.generatedSources[supplied],expected=generatedProviders.get(supplied);assert.ok(expected,'Unknown virtual provider.');assert.equal(value.provider,expected); const provider=realpathSync(join(root,value.provider));assert.ok(inside(modules,provider));assert.equal(digest(readFileSync(provider)),value.sha256); const owner=npmNoticeInputs(root,[provider])[0]; - if(supplied===expoExternalRequire){assert.equal(owner.name,'@expo/cli');assert.equal(owner.packagePath,'node_modules/expo/node_modules/@expo/cli');assert.equal(owner.id,value.component,'Virtual provider owner/version changed.');} + if(expoGeneratedSources.has(supplied)){assert.equal(owner.name,'@expo/cli');assert.equal(owner.packagePath,'node_modules/expo/node_modules/@expo/cli');assert.equal(owner.id,value.component,'Virtual provider owner/version changed.');} return {path:supplied,generated:true,provider:value.provider,sha256:value.sha256,component:owner.id}; }); const path=step('source-path',()=>{ diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs index 1e3f6e2b..46871807 100644 --- a/scripts/app-notices.test.mjs +++ b/scripts/app-notices.test.mjs @@ -248,16 +248,19 @@ test('actual discovery setup trap keeps original failures and writes finite phas const invalid=spawnSync(process.execPath,[cli,'setup-failure','android','--phase','PRIVATE_UNKNOWN_PHASE'],{cwd:root,encoding:'utf8',timeout:30000});assert.equal(invalid.status,1);assert.ok(!invalid.stderr.includes('PRIVATE_UNKNOWN_PHASE')); })); -test('the exact observed Expo external-require virtual ID resolves only to its pinned installed provider',()=>{ - const id='\0polyfill:external-require';assert.equal(digest(id),'f32cbb35a430d4c6ee0f555a934acc8f6a83aff8e2b19c414f6d756479b8f03e'); +test('the explicit Expo virtual IDs resolve only to their pinned installed provider',()=>{ + assert.equal(digest('\0polyfill:external-require'),'f32cbb35a430d4c6ee0f555a934acc8f6a83aff8e2b19c414f6d756479b8f03e'); const provenance=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))); + for(const id of ['\0polyfill:external-require','\0polyfill:environment-variables','\0polyfill:assets-registry']){ const result=capturedSource(process.cwd(),id,provenance); assert.deepEqual(result,{path:id,generated:true,provider:'node_modules/expo/node_modules/@expo/cli/build/src/start/server/metro/withMetroMultiPlatform.js',sha256:'50012292a38cb679ca3f692c5a462de57bebe0f69d5c862e937141a3fc54343f',component:'npm:@expo/cli@57.0.26'}); + } assert.deepEqual(provenance.components,[],'A technical provider mapping is not reviewed app-library notice coverage.'); }); test('virtual provider mapping rejects modified bytes, wrong path/owner/version and every unknown virtual ID',()=>fixture(({root,file})=>{ - const id='\0polyfill:external-require',provider='node_modules/expo/node_modules/@expo/cli/build/src/start/server/metro/withMetroMultiPlatform.js',packagePath='node_modules/expo/node_modules/@expo/cli',content='export const fixture=1;'; + for(const id of ['\0polyfill:external-require','\0polyfill:environment-variables','\0polyfill:assets-registry']){ + const provider='node_modules/expo/node_modules/@expo/cli/build/src/start/server/metro/withMetroMultiPlatform.js',packagePath='node_modules/expo/node_modules/@expo/cli',content='export const fixture=1;'; const pkg={name:'@expo/cli',version:'1.2.3'},lock={packages:{[packagePath]:{version:'1.2.3'}}};file('package-lock.json',lock);file(packagePath+'/package.json',pkg);file(packagePath+'/LICENSE',mit);file(provider,content); const record={provider,sha256:digest(content),component:'npm:@expo/cli@1.2.3'},provenance={generatedSources:{[id]:record}}; assert.equal(capturedSource(root,id,provenance).component,record.component); @@ -266,10 +269,11 @@ test('virtual provider mapping rejects modified bytes, wrong path/owner/version file(packagePath+'/package.json',{...pkg,version:'9.9.9'});assert.throws(()=>capturedSource(root,id,provenance)); file('package-lock.json',{packages:{[packagePath]:{version:'9.9.9'}}});assert.throws(()=>capturedSource(root,id,provenance),'Updating installed and locked version cannot evade the committed component pin.'); file('package-lock.json',lock);file(packagePath+'/package.json',{...pkg,name:'private-other'});assert.throws(()=>capturedSource(root,id,provenance));file(packagePath+'/package.json',pkg); - for(const unknown of ['\0polyfill:unknown','\0polyfill:environment-variables','\0polyfill:assets-registry']){ + for(const unknown of ['\0polyfill:unknown','\0node:private','\0shim:private']){ assert.throws(()=>capturedSource(root,unknown,provenance)); assert.throws(()=>capturedSource(root,unknown,{generatedSources:{[unknown]:record}}),'An arbitrary virtual ID cannot reuse an allowed provider.'); } + } })); test('setup failure CLI retains only matching finite Gradle collector context and strips every untrusted record',()=>fixture(({root,file})=>{ @@ -286,3 +290,27 @@ test('setup failure CLI retains only matching finite Gradle collector context an } file(path,known);assert.equal(cli('prebuild').status,0);assert.deepEqual(JSON.parse(readFileSync(join(root,path))),{...generic,setup:{phase:'prebuild',errorCategory:'command-failed'}}); })); + + +test('actual pinned native polyfills and this app font asset route prove the two additional exact virtual providers',async()=>{ + const {createRequire}=await import('node:module'),require=createRequire(import.meta.url); + const provider='node_modules/expo/node_modules/@expo/cli/build/src/start/server/metro/withMetroMultiPlatform.js',source=readFileSync(provider,'utf8'); + assert.equal(digest(source),'50012292a38cb679ca3f692c5a462de57bebe0f69d5c862e937141a3fc54343f'); + const providerRequire=createRequire(join(process.cwd(),provider)),tree=require('@babel/parser').parse(source,{sourceType:'script'}); + const definition=tree.program.body.find(value=>value.type==='FunctionDeclaration'&&value.id.name==='withWebPolyfills');assert.ok(definition); + const fn=Function('_metroVirtualModules','require','debug','return ('+source.slice(definition.start,definition.end)+');')(providerRequire('./metroVirtualModules.js'),providerRequire,()=>{}); + const makeBundler=()=>({transformFile(){},_depGraph:{_fileSystem:{getSha1(){return 'source-only';}}}}),polyfillBundler=makeBundler(); + const configured=fn({serializer:{getPolyfills:()=>[]}},{getMetroBundler:()=>polyfillBundler}); + const polyfills=configured.serializer.getPolyfills({platform:'ios'}); + assert.deepEqual(polyfills.slice(0,2),['\0polyfill:external-require','\0polyfill:environment-variables']); + assert.equal(polyfillBundler._depGraph._fileSystem.expoVirtualModules.get('\0polyfill:environment-variables').toString(),'//','This proof never generates or reads dynamic environment values.'); + assert.ok(readFileSync('App.tsx','utf8').includes("from '@expo/vector-icons/MaterialCommunityIcons'")); + assert.ok(readFileSync('node_modules/@expo/vector-icons/build/MaterialCommunityIcons.js','utf8').includes('./vendor/react-native-vector-icons/Fonts/MaterialCommunityIcons.ttf')); + const registry='@react-native/assets-registry/registry';assert.ok(readFileSync('node_modules/@expo/metro-config/build/ExpoMetroConfig.js','utf8').includes("assetRegistryPath: '"+registry+"'")); + const ast=require(join(process.cwd(),'node_modules/metro/src/Bundler/util.js')).generateAssetCodeFileAst(registry,{__packager_asset:true,name:'MaterialCommunityIcons',type:'ttf',scales:[1]}); + const requires=[];const walk=value=>{if(!value||typeof value!=='object')return;if(value.type==='CallExpression'&&value.callee?.name==='require')requires.push(value.arguments[0].value);for(const child of Object.values(value))if(Array.isArray(child))child.forEach(walk);else if(child&&typeof child==='object')walk(child);};walk(ast);assert.deepEqual(requires,[registry]); + const bundler=makeBundler(),config={projectRoot:process.cwd(),resolver:{platforms:['ios','android'],sourceExts:['js','jsx','ts','tsx','json'],nodeModulesPaths:[],unstable_conditionNames:[],unstable_conditionsByPlatform:{}},serializer:{},transformer:{asyncRequireModulePath:'@expo/metro-runtime/async-require',assetRegistryPath:registry}}; + const extended=providerRequire(providerRequire.resolve('./withMetroMultiPlatform.js')).withExtendedResolver(config,{isTsconfigPathsEnabled:false,isExporting:true,isReactServerComponentsEnabled:false,getMetroBundler:()=>bundler}); + for(const platform of ['ios','android'])assert.deepEqual(extended.resolver.resolveRequest({originModulePath:join(process.cwd(),'node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/MaterialCommunityIcons.ttf'),dev:false,customResolverOptions:{},resolveRequest(){throw new Error('Unintercepted asset request');}},registry,platform),{type:'sourceFile',filePath:'\0polyfill:assets-registry'}); + assert.ok(bundler._depGraph._fileSystem.expoVirtualModules.has('\0polyfill:assets-registry')); +}); diff --git a/scripts/notices-runtime-root.test.groovy b/scripts/notices-runtime-root.test.groovy index ec7dd7f4..7f983217 100644 --- a/scripts/notices-runtime-root.test.groovy +++ b/scripts/notices-runtime-root.test.groovy @@ -32,7 +32,7 @@ try { // Included builds need not repeat the main invocation's CLI properties. def parent = actual == included ? [startParameter: [projectProperties: properties]] : null def invocation = [parent: parent, rootProject: target, startParameter: [projectProperties: parent == null ? properties : [:]], projectsEvaluated: { body -> body.call() }] - if (fault.diagnostics) invocation.providers = [environmentVariable: { name -> [orNull: name == 'GITHUB_WORKSPACE' ? main.parentFile.path : name == 'GITHUB_SHA' ? 'a' * 40 : null] }] + if (fault.diagnostics) target.providers = [environmentVariable: { name -> [orNull: name == 'GITHUB_WORKSPACE' ? main.parentFile.path : name == 'GITHUB_SHA' ? 'a' * 40 : null] }] new GroovyShell(this.class.classLoader, new Binding([gradle: invocation]), config).evaluate(source) [registered: registered, lookups: lookups] } diff --git a/scripts/notices-runtime.init.gradle b/scripts/notices-runtime.init.gradle index 35a196fb..7f18275e 100644 --- a/scripts/notices-runtime.init.gradle +++ b/scripts/notices-runtime.init.gradle @@ -7,8 +7,8 @@ import org.gradle.api.artifacts.component.ProjectComponentIdentifier // Diagnostic output is optional and finite. Never serialize Throwable text. def recordNoticeFailure = { File mainRoot, String phase, Throwable failure -> try { - def workspaceValue = gradle.providers.environmentVariable('GITHUB_WORKSPACE').orNull - def source = gradle.providers.environmentVariable('GITHUB_SHA').orNull + def workspaceValue = gradle.rootProject.providers.environmentVariable('GITHUB_WORKSPACE').orNull + def source = gradle.rootProject.providers.environmentVariable('GITHUB_SHA').orNull if (!workspaceValue || !(source ==~ /[a-f0-9]{40}/) || mainRoot == null) return def workspace = new File(workspaceValue).canonicalFile if (mainRoot != new File(workspace, 'android').canonicalFile) return From 77a2f9d60f479c53705e15b5adf5ca91dd39dfd5 Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:52:35 +0800 Subject: [PATCH 20/37] Bind CocoaPods linked framework inputs to their locked package --- docs/app-notices.md | 8 +++++- scripts/app-notices.mjs | 25 +++++++++++++------ scripts/native-notice-inputs.mjs | 35 +++++++++++++++++++++++---- scripts/native-notice-inputs.test.mjs | 33 ++++++++++++++++++++++--- 4 files changed, 85 insertions(+), 16 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index 6c105ac8..2559fe47 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -184,7 +184,13 @@ Metro inputs are selected files, not every declared or installed package. Nested packages have their own licence boundary. CocoaPods inputs include resolved pod versions, generated acknowledgements, actual source notice files and vendored framework/library names. Raw Pod lock/plist/notice bytes must be valid UTF-8; -invalid bytes are rejected before decoding, never repaired. Gradle inputs are the +invalid bytes are rejected before decoding, never repaired. A framework symlink +may cross a Pod sandbox only when its exact literal path appears in the hashed +CocoaPods xcframework input list and its target belongs to the locked external +podspec's installed npm package. The link target, owning package, podspec and +selected target bytes are fingerprinted. Undeclared links, targets outside that +package and nested links escaping the selected framework remain rejected; a +declared link does not supply the vendor's licence. Gradle inputs are the actual release runtime resolution and artifact hashes. A shared bounded scanner walks Pods and autolinked Gradle native subtrees, fingerprints source files, and finds source-only `vendor`/`third_party` children as well as binary vendors. diff --git a/scripts/app-notices.mjs b/scripts/app-notices.mjs index e4170dc3..9b640080 100644 --- a/scripts/app-notices.mjs +++ b/scripts/app-notices.mjs @@ -1,7 +1,7 @@ // Build-time notice evidence. Importing never starts Metro, a device or a build. import assert from 'node:assert/strict'; import {createHash} from 'node:crypto'; -import {closeSync,existsSync,openSync,readFileSync,readSync,readdirSync,realpathSync,statSync} from 'node:fs'; +import {closeSync,existsSync,openSync,readFileSync,readSync,readdirSync,realpathSync,statSync,lstatSync} from 'node:fs'; import {dirname,isAbsolute,join,relative,resolve,sep} from 'node:path'; import {fileURLToPath} from 'node:url'; import {isDeepStrictEqual} from 'node:util'; @@ -38,21 +38,32 @@ function nearbyNotices(boundary,start,prefix){ // Shared native-source boundary walk. Generated Gradle output is excluded only // through the actual project build-directory metadata, never a vendor wildcard. -export function nativeSourceNotices(root,supplied,prefix,{excluded=[]}={}){ +export function nativeSourceNotices(root,supplied,prefix,{excluded=[],linkedSources=[]}={}){ const boundary=realpathSync(supplied),project=realpathSync(root),modules=existsSync(join(root,'node_modules'))?realpathSync(join(root,'node_modules')):null; assert.ok((modules&&inside(modules,boundary))||boundary.startsWith(join(project,'ios/Pods')+sep)||boundary.startsWith(join(project,'modules')+sep),'Native source must be an identified package/Pod/local module, not arbitrary project data.'); const canonicalFuture=path=>{path=resolve(path);let ancestor=path;while(!existsSync(ancestor))ancestor=dirname(ancestor);return resolve(realpathSync(ancestor),relative(ancestor,path));}; + const links=new Map(); + for(const value of linkedSources){ + const {source,packagePath,component,target:logicalTarget}=value,name=safeRelative(source),packageName=safeRelative(packagePath),targetName=safeRelative(logicalTarget);assert.ok(name.endsWith('.xcframework')&&!links.has(name),'Native source link escapes its component.'); + const link=join(boundary,name),owner=npmNoticeInputs(root,[join(root,packageName,'package.json')])[0]; + assert.ok(owner?.packagePath===packageName&&owner.id===component&&lstatSync(link).isSymbolicLink(),'Native source link escapes its component.'); + const packageRoot=realpathSync(join(root,packageName)),target=realpathSync(join(root,targetName)); + assert.ok(inside(packageRoot,target)&&realpathSync(link)===target&&statSync(target).isDirectory(),'Native source link escapes its component.'); + links.set(name,{source:name,target:targetName,packagePath:owner.packagePath,component:owner.id,targetRoot:target}); + } const omitted=new Set(excluded.map(canonicalFuture)),files=[],notices=[],vendors=new Set(),visited=new Set();let count=0,total=0; - function walk(folder,logical=''){ - const canonical=realpathSync(folder);assert.ok(inside(boundary,canonical),'Native source link escapes its component.'); + function walk(folder,logical='',scope=boundary){ + const canonical=realpathSync(folder);assert.ok(inside(scope,canonical),'Native source link escapes its component.'); if(visited.has(canonical))return;visited.add(canonical); for(const entry of readdirSync(folder,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name))){ assert.ok(++count<=100000,'Too many native source entries.'); const path=join(folder,entry.name),name=logical?logical+'/'+entry.name:entry.name; if(entry.name==='.git'||omitted.has(resolve(path)))continue; - const actual=realpathSync(path);assert.ok(inside(boundary,actual),'Native source link escapes its component.');const info=statSync(path); + const actual=realpathSync(path),link=links.get(name);let nextScope=scope; + if(!inside(scope,actual)){assert.ok(link&&link.targetRoot===actual,'Native source link escapes its component.');nextScope=actual;} + const info=statSync(path); if(vendorName.test(entry.name)||(info.isDirectory()&&vendorName.test(logical.split('/').at(-1)??''))||/\.(?:xcframework|framework|a|so|jar|aar)$/.test(entry.name))vendors.add(name); - if(info.isDirectory()){walk(path,name);continue;} + if(info.isDirectory()){walk(path,name,nextScope);continue;} assert.ok(info.isFile()&&info.size<=512*1024*1024&&(total+=info.size)<=2*1024*1024*1024,'Native source inventory exceeds its bound.'); files.push({path:name,sha256:fileDigest(path)}); if(name.split('/').some(part=>noticeName.test(part)))notices.push(candidate(boundary,path,prefix+'/'+name)); @@ -60,7 +71,7 @@ export function nativeSourceNotices(root,supplied,prefix,{excluded=[]}={}){ } walk(boundary); const vendorNotices=Object.fromEntries([...vendors].sort().map(value=>[value,notices.filter(item=>item.source.startsWith(prefix+'/'+value+'/')).map(item=>item.source)])); - return {notices,vendors:[...vendors].sort(),vendorNotices,nativeInputs:{[prefix]:{sha256:digest(JSON.stringify(files)),files:files.length,excludedBuildPaths:[...omitted].filter(path=>inside(boundary,path)).map(path=>safeRelative(relative(boundary,path))).sort()}}}; + return {notices,vendors:[...vendors].sort(),vendorNotices,nativeInputs:{[prefix]:{sha256:digest(JSON.stringify(files)),files:files.length,excludedBuildPaths:[...omitted].filter(path=>inside(boundary,path)).map(path=>safeRelative(relative(boundary,path))).sort(),...(links.size?{linkedSources:[...links.values()].map(({targetRoot,...value})=>value).sort((a,b)=>a.source.localeCompare(b.source))}:{})}}}; } // Use the actual Metro-selected files, not all installed/declared dependencies. diff --git a/scripts/native-notice-inputs.mjs b/scripts/native-notice-inputs.mjs index 8e0bc92d..3c21b7f2 100644 --- a/scripts/native-notice-inputs.mjs +++ b/scripts/native-notice-inputs.mjs @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; import {execFileSync} from 'node:child_process'; -import {existsSync,readFileSync,readdirSync,realpathSync} from 'node:fs'; +import {existsSync,readFileSync,readdirSync,realpathSync,lstatSync,statSync} from 'node:fs'; import {basename,dirname,join,relative,resolve,sep} from 'node:path'; import {createRequire} from 'node:module'; -import {digest,nativeNoticeInput,npmNoticeInputs,nativeSourceNotices,noticeText} from './app-notices.mjs'; +import {digest,nativeNoticeInput,npmNoticeInputs,nativeSourceNotices,noticeText,capturedSource,safeRelative} from './app-notices.mjs'; const require=createRequire(import.meta.url),archiveReader=new URL('./notice-archive.py',import.meta.url); @@ -31,6 +31,28 @@ export function noticeFailureContext(error){const value=contexts.get(error);retu export function inspectNoticeArchive(path,payloadPath,bundlePath){ return JSON.parse(execFileSync('python3',[archiveReader.pathname,path,...(payloadPath?[payloadPath]:[]),...(bundlePath?[bundlePath]:[])],{encoding:'utf8',maxBuffer:32*1024*1024,timeout:60000,stdio:['ignore','pipe','pipe']})); } +// Only CocoaPods-selected literal frameworks may cross a Pod sandbox, and +// only into the exact installed package owning its locked external podspec. +export function podLinkedSources(root,name,source,external){ + const list=join(root,'ios/Pods/Target Support Files',safeRelative(name),name+'-xcframeworks-input-files.xcfilelist'); + if(!external?.[':podspec']||!existsSync(list))return {linkedSources:[]}; + const bytes=readFileSync(list),text=noticeText(bytes,1024*1024),prefix='${PODS_ROOT}/'+name+'/',selected=[]; + for(const line of text.split(/\r?\n/).filter(Boolean)){ + if(!line.endsWith('.xcframework'))continue; + assert.ok(line.startsWith(prefix),'Native source link escapes its component.'); + const item=safeRelative(line.slice(prefix.length));assert.ok(!/[*?{}$]/.test(item),'Native source link escapes its component.');selected.push(item); + } + const candidates=[],boundary=realpathSync(source),frameworkInputList={source:safeRelative(relative(root,list)),sha256:digest(bytes)}; + for(const item of new Set(selected)){ + const path=join(source,item);assert.ok(existsSync(path)&&statSync(path).isDirectory(),'Native source link escapes its component.');if(!lstatSync(path).isSymbolicLink())continue; + const target=realpathSync(path);if(target===boundary||target.startsWith(boundary+sep))continue;candidates.push({source:item,target}); + } + if(!candidates.length)return {linkedSources:[],frameworkInputList}; + const spec=resolve(root,'ios',external[':podspec']),owner=npmNoticeInputs(root,[spec])[0];assert.ok(owner,'Native source link escapes its component.'); + const modules=realpathSync(join(root,'node_modules')),linkedSources=candidates.map(value=>({source:value.source,target:'node_modules/'+safeRelative(relative(modules,value.target)),packagePath:owner.packagePath,component:owner.id})); + return {linkedSources,frameworkInputList,externalPodspec:capturedSource(root,spec)}; +} + export function podNoticeInputs(root){ const lockPath=join(root,'ios/Podfile.lock'),support=join(root,'ios/Pods/Target Support Files'); if(!existsSync(lockPath)||!existsSync(support))return {components:[],unresolved:[{component:'native:ios',reason:'missing-resolved-pod-inputs'}],nativeFiles:[]}; @@ -53,10 +75,13 @@ export function podNoticeInputs(root){ }); const unresolved=[],components=[]; for(const [name,version] of versions){ - const notices=texts.get(name)??[],external=lock['EXTERNAL SOURCES']?.[name]?.[':path'];let scan={vendors:[],vendorNotices:{},nativeInputs:{},notices:[]}; - const source=external?resolve(root,'ios',external):join(root,'ios/Pods',name); + const notices=texts.get(name)??[],external=lock['EXTERNAL SOURCES']?.[name];let scan={vendors:[],vendorNotices:{},nativeInputs:{},notices:[]}; + const source=external?.[':path']?resolve(root,'ios',external[':path']):join(root,'ios/Pods',name); if(!existsSync(source))unresolved.push({component:`cocoapods:${name}@${version}`,reason:'missing-resolved-pod-source'}); - else scan=noticeInputStep('pod-source-scan','cocoapods',()=>nativeSourceNotices(root,source,`cocoapods:${name}`),`cocoapods:${name}@${version}`); + else scan=noticeInputStep('pod-source-scan','cocoapods',()=>{ + const {linkedSources,...metadata}=podLinkedSources(root,name,source,external),value=nativeSourceNotices(root,source,`cocoapods:${name}`,{linkedSources}); + Object.assign(value.nativeInputs[`cocoapods:${name}`],metadata);return value; + },`cocoapods:${name}@${version}`); components.push(noticeInputStep('pod-notices','cocoapods',()=>nativeNoticeInput({id:`cocoapods:${name}@${version}`,name,version,ecosystem:'cocoapods',...scan,notices:[...notices,...scan.notices].sort((a,b)=>a.source.localeCompare(b.source))}),`cocoapods:${name}@${version}`)); } return {components,unresolved,nativeFiles:[],inputHashes:{podLock:digest(readFileSync(lockPath)),podAcknowledgements:digest(JSON.stringify(acknowledgements))}}; diff --git a/scripts/native-notice-inputs.test.mjs b/scripts/native-notice-inputs.test.mjs index b69e0ca7..59725b4b 100644 --- a/scripts/native-notice-inputs.test.mjs +++ b/scripts/native-notice-inputs.test.mjs @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import {execFileSync} from 'node:child_process'; -import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,rmSync} from 'node:fs'; +import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,rmSync,symlinkSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {createRequire} from 'node:module'; import test from 'node:test'; -import {podNoticeInputs,gradleNoticeInputs,unionNoticeInputs,inspectNoticeArchive,noticeInputStep,noticeFailureContext} from './native-notice-inputs.mjs'; -import {assertPackagedNativeCoverage,assertSourceNoticePayload,digest,npmNoticeInputs,resolveNoticeCoverage} from './app-notices.mjs'; +import {podNoticeInputs,gradleNoticeInputs,unionNoticeInputs,inspectNoticeArchive,noticeInputStep,noticeFailureContext,podLinkedSources} from './native-notice-inputs.mjs'; +import {assertPackagedNativeCoverage,assertSourceNoticePayload,digest,npmNoticeInputs,resolveNoticeCoverage,nativeSourceNotices} from './app-notices.mjs'; const require=createRequire(import.meta.url),plist=require('@expo/plist'); const mit=readFileSync(new URL('../node_modules/react-native/LICENSE',import.meta.url),'utf8'); const approval=(item,platform='ios')=>({schema:1,components:[{platform,component:item.id,reviewedVendors:item.vendors,nativeInputs:item.nativeInputs??{},notices:item.notices.map(({source,sha256})=>({source,sha256})),upstream:'https://example.invalid/pinned/native',sectionSHA256:digest([...item.notices].sort((a,b)=>a.source.localeCompare(b.source)).map(value=>value.body).join('\n\n'))}]}); @@ -176,3 +176,30 @@ test('source diagnostics keep ReferenceError finite and snapshot changing or thr assert.equal(counts[throwing],1);assert.ok(Object.values(counts).every(count=>count<=1)); } }); + + +test('Pods follow only exact selected frameworks into the locked podspec package and still require vendor notices',()=>fixture(({root,file})=>{ + const pkg='node_modules/qa-sdk',pod='ios/Pods/QA',list='ios/Pods/Target Support Files/QA/QA-xcframeworks-input-files.xcfilelist'; + const lock={PODS:['QA (1)'],'EXTERNAL SOURCES':{QA:{':podspec':'../node_modules/qa-sdk/ios/QA.podspec'}}},packages={packages:{[pkg]:{version:'1'}}}; + file('package-lock.json',packages);file(pkg+'/package.json',{name:'qa-sdk',version:'1'});file(pkg+'/LICENSE',mit);file(pkg+'/ios/QA.podspec','public synthetic podspec');file('ios/Podfile.lock',lock); + file('ios/Pods/Target Support Files/Pods-QA/Pods-QA-acknowledgements.plist',plist.default.build({PreferenceSpecifiers:[{Title:'QA',FooterText:mit}]}));file(pod+'/LICENSE',mit); + const target=pkg+'/prebuilds/spm-deps/Shared/debug/Shared.xcframework',binary=target+'/Shared',link=join(root,pod,'Shared.xcframework');file(binary,Buffer.from([0xcf,0xfa,0xed,0xfe,1]));symlinkSync(join(root,target),link); + const input='${PODS_ROOT}/Target Support Files/QA/QA-xcframeworks.sh\n${PODS_ROOT}/QA/Shared.xcframework\n';file(list,input); + const initial=podNoticeInputs(root).components[0],fingerprint=initial.nativeInputs['cocoapods:QA']; + assert.deepEqual(fingerprint.linkedSources,[{source:'Shared.xcframework',target,packagePath:pkg,component:'npm:qa-sdk@1'}]);assert.equal(fingerprint.frameworkInputList.sha256,digest(input));assert.equal(fingerprint.externalPodspec.component,'npm:qa-sdk@1');assert.equal(fingerprint.externalPodspec.sha256,digest('public synthetic podspec')); + assert.ok(!JSON.stringify(initial).includes(root));assert.ok(initial.vendors.includes('Shared.xcframework'));assert.deepEqual(initial.vendorNotices['Shared.xcframework'],[]); + assert.equal(resolveNoticeCoverage([initial],approval(initial)).unresolved[0].reason,'missing-vendored-notice','The owning package and Pod acknowledgement cannot license a separate vendor.'); + file(target+'/LICENSE','Distinct vendor licence\n');const complete=podNoticeInputs(root).components[0],reviewed=approval(complete);assert.equal(resolveNoticeCoverage([complete],reviewed).status,'READY'); + file(binary,Buffer.from([0xcf,0xfa,0xed,0xfe,2]));assert.equal(resolveNoticeCoverage(podNoticeInputs(root).components,reviewed).unresolved[0].reason,'native-source-digest-mismatch');file(binary,Buffer.from([0xcf,0xfa,0xed,0xfe,1])); + file(list,input+'\n');assert.equal(resolveNoticeCoverage(podNoticeInputs(root).components,reviewed).unresolved[0].reason,'native-source-digest-mismatch');file(list,input); + file(pkg+'/ios/QA.podspec','changed public synthetic podspec');assert.equal(resolveNoticeCoverage(podNoticeInputs(root).components,reviewed).unresolved[0].reason,'native-source-digest-mismatch');file(pkg+'/ios/QA.podspec','public synthetic podspec'); + const descriptor=podLinkedSources(root,'QA',join(root,pod),lock['EXTERNAL SOURCES'].QA).linkedSources[0]; + assert.throws(()=>nativeSourceNotices(root,join(root,pod),'cocoapods:QA'),'Undeclared boundary crossing is not generally accepted.'); + const guarded=nativeSourceNotices(root,join(root,pod),'cocoapods:QA',{linkedSources:[{...descriptor,privateBody:'PRIVATE_BODY'}]});assert.ok(!JSON.stringify(guarded).includes('PRIVATE_BODY')); + for(const text of ['${PODS_ROOT}/QA/*.xcframework\n','${PODS_ROOT}/QA/Missing.xcframework\n','${PODS_ROOT}/QA/../Shared.xcframework\n']){file(list,text);assert.throws(()=>podLinkedSources(root,'QA',join(root,pod),lock['EXTERNAL SOURCES'].QA));}file(list,'${PODS_ROOT}/Target Support Files/QA/QA-xcframeworks.sh\n');assert.throws(()=>podNoticeInputs(root));file(list,input); + file('node_modules/other/package.json',{name:'other',version:'1'});file('node_modules/other/LICENSE',mit);file('node_modules/other/ios/Other.podspec','other');file('node_modules/other/Other.xcframework/Other','private');file('package-lock.json',{packages:{...packages.packages,'node_modules/other':{version:'1'}}}); + rmSync(link);symlinkSync(join(root,'node_modules/other/Other.xcframework'),link);assert.throws(()=>podNoticeInputs(root),'A selected link cannot leave its exact podspec-owning package.');rmSync(link);symlinkSync(join(root,target),link); + file('ios/Podfile.lock',{...lock,'EXTERNAL SOURCES':{QA:{':podspec':'../node_modules/other/ios/Other.podspec'}}});assert.throws(()=>podNoticeInputs(root));file('ios/Podfile.lock',lock); + file(pkg+'/package.json',{name:'qa-sdk',version:'2'});assert.throws(()=>podNoticeInputs(root),'An installed owner differing from package-lock cannot qualify the link.');file(pkg+'/package.json',{name:'qa-sdk',version:'1'}); + file(pkg+'/outside/NOTICE','Not inside the selected vendor root');symlinkSync(join(root,pkg,'outside'),join(root,target,'outside'));assert.throws(()=>podNoticeInputs(root),'Nested links cannot escape even into another part of the owner package.'); +})); From 84be432feaf7a7cda2de550dc931826fff78b5a1 Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 08:01:23 +0800 Subject: [PATCH 21/37] Record finite bounded Gradle cause class fingerprints --- docs/app-notices.md | 11 ++++++++-- scripts/app-notices.test.mjs | 7 +++++- scripts/capture-app-notices.mjs | 8 +++++-- scripts/notices-runtime-root.test.groovy | 28 ++++++++++++++++++++++-- scripts/notices-runtime.init.gradle | 12 ++++++++-- 5 files changed, 57 insertions(+), 9 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index 2559fe47..78c93a56 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -78,8 +78,15 @@ exits with the original nonzero setup status, including if metadata writing fails. The fixed metadata upload remains separate from public notice bodies. If reached, the existing Gradle collector adds only a fixed root-binding, task-registration, runtime-resolution or artifact-collection phase and a closed -public/base Throwable class. It reads no exception message or stack and always -rethrows the same Throwable; a failed/outside-workspace metadata write is ignored. +public/base Throwable class. A standard `getCause()` chain includes at most eight +entries, starting with the thrown object; each contains only that closed class +enum and the SHA-256 of its fully qualified class name. Identity cycles, the +eight-entry limit and throwing cause getters have fixed termination labels. +Gradle 9.3.1's [multi-cause implementation](https://github.com/gradle/gradle/blob/v9.3.1/platforms/core-runtime/stdlib-java-extensions/src/main/java/org/gradle/internal/exceptions/DefaultMultiCauseException.java#L111) +exposes only the first cause through `getCause()`, so this does not enumerate all +resolution failures or prove a root cause. It reads no raw class name into the +report, exception message or stack and always rethrows the same Throwable; a +failed/outside-workspace metadata write is ignored. The shell writer retains this context only after exact schema, source and enum validation, then reconstructs the finite report. Failures before these callbacks or during other Gradle/plugin configuration still have only the generic setup diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs index 46871807..eedd1d53 100644 --- a/scripts/app-notices.test.mjs +++ b/scripts/app-notices.test.mjs @@ -281,13 +281,18 @@ test('setup failure CLI retains only matching finite Gradle collector context an file('fixture.txt','synthetic');git('init','--quiet');git('add','.');git('-c','user.name=Notice test','-c','user.email=notice-test@example.invalid','commit','--quiet','-m','Synthetic collector metadata'); const sourceCommit=git('rev-parse','HEAD').toString().trim(),path='build/notices/android/failure.json'; const generic={schema:1,status:'INCOMPLETE',complete:false,sourceCommit,platform:'android',stage:'setup',setup:{phase:'gradle-resolution',errorCategory:'command-failed'}}; - const known={...generic,setup:{...generic.setup,collector:{phase:'runtime-resolution',errorClass:'ResolveException'}}}; + const chain=[{errorClass:'ResolveException',classSHA256:digest('org.gradle.api.artifacts.ResolveException')},{errorClass:'Throwable',classSHA256:digest('PRIVATE_UNKNOWN_CLASS')}],collector={phase:'runtime-resolution',errorClass:'ResolveException',causeChain:chain,causeChainEnd:'end'}; + const known={...generic,setup:{...generic.setup,collector}}; const cli=phase=>spawnSync(process.execPath,[fileURLToPath(new URL('./capture-app-notices.mjs',import.meta.url)),'setup-failure','android','--phase',phase],{cwd:root,encoding:'utf8',timeout:30000}); file(path,known);assert.equal(cli('gradle-resolution').status,0);assert.deepEqual(JSON.parse(readFileSync(join(root,path))),known); for(const changed of [{...known,sourceCommit:'b'.repeat(40)},{...known,privateMessage:'PRIVATE_BODY'},{...known,setup:{...known.setup,collector:{phase:'PRIVATE_PHASE',errorClass:'ResolveException'}}},{...known,setup:{...known.setup,collector:{phase:'runtime-resolution',errorClass:'PRIVATE_CLASS'}}},'PRIVATE_BODY'.repeat(500)]){ file(path,changed);const result=cli('gradle-resolution');assert.equal(result.status,0); const value=JSON.parse(readFileSync(join(root,path)));assert.deepEqual(value,generic);assert.ok(!(JSON.stringify(value)+result.stdout+result.stderr).includes('PRIVATE')); } + for(const bad of [{causeChain:[]},{causeChain:Array(9).fill(chain[0])},{causeChain:[{...chain[0],message:'PRIVATE_MESSAGE'}]},{causeChain:[{...chain[0],classSHA256:'PRIVATE_CLASS'}]},{causeChain:[{...chain[0],classSHA256:[chain[0].classSHA256]}]},{causeChain:[{...chain[0],errorClass:'PRIVATE_CLASS'}]},{causeChain:[chain[1]]},{causeChainEnd:'PRIVATE_END'},{causeChainEnd:'limit'}]){ + file(path,{...known,setup:{...known.setup,collector:{...collector,...bad}}});const result=cli('gradle-resolution');assert.equal(result.status,0);assert.deepEqual(JSON.parse(readFileSync(join(root,path))),generic);assert.ok(!(readFileSync(join(root,path),'utf8')+result.stdout+result.stderr).includes('PRIVATE')); + } + for(const end of ['end','cycle','unreadable','limit']){const causes=end==='limit'?Array(8).fill(chain[0]):chain;const value={...known,setup:{...known.setup,collector:{...collector,causeChain:causes,causeChainEnd:end}}};file(path,value);assert.equal(cli('gradle-resolution').status,0);assert.deepEqual(JSON.parse(readFileSync(join(root,path))),value);} file(path,known);assert.equal(cli('prebuild').status,0);assert.deepEqual(JSON.parse(readFileSync(join(root,path))),{...generic,setup:{phase:'prebuild',errorCategory:'command-failed'}}); })); diff --git a/scripts/capture-app-notices.mjs b/scripts/capture-app-notices.mjs index edebd1b4..70b68b92 100644 --- a/scripts/capture-app-notices.mjs +++ b/scripts/capture-app-notices.mjs @@ -119,8 +119,12 @@ function retainedCollectorFailure(path,sourceCommit,phase){ assert.deepEqual(Object.keys(value).sort(),['schema','status','complete','sourceCommit','platform','stage','setup'].sort()); assert.equal(value.schema,1);assert.equal(value.status,'INCOMPLETE');assert.equal(value.complete,false);assert.equal(value.sourceCommit,sourceCommit);assert.equal(value.platform,'android');assert.equal(value.stage,'setup'); assert.deepEqual(Object.keys(setup).sort(),['phase','errorCategory','collector'].sort());assert.equal(setup.phase,phase);assert.equal(setup.errorCategory,'command-failed'); - assert.deepEqual(Object.keys(collector).sort(),['phase','errorClass'].sort());assert.ok(['root-binding','task-registration','runtime-resolution','artifact-collection'].includes(collector.phase));assert.ok(['ResolveException','GradleException','IOException','IllegalArgumentException','IllegalStateException','SecurityException','Error','Throwable'].includes(collector.errorClass)); - return {phase:collector.phase,errorClass:collector.errorClass}; + const classes=['ResolveException','GradleException','IOException','IllegalArgumentException','IllegalStateException','SecurityException','Error','Throwable']; + assert.deepEqual(Object.keys(collector).sort(),['phase','errorClass','causeChain','causeChainEnd'].sort());assert.ok(['root-binding','task-registration','runtime-resolution','artifact-collection'].includes(collector.phase));assert.ok(classes.includes(collector.errorClass)); + assert.ok(Array.isArray(collector.causeChain)&&collector.causeChain.length>=1&&collector.causeChain.length<=8);assert.ok(['end','limit','cycle','unreadable'].includes(collector.causeChainEnd));assert.ok(collector.causeChainEnd!=='limit'||collector.causeChain.length===8); + const causeChain=collector.causeChain.map(value=>{assert.deepEqual(Object.keys(value).sort(),['errorClass','classSHA256'].sort());assert.ok(classes.includes(value.errorClass)&&typeof value.classSHA256==='string'&&/^[a-f0-9]{64}$/.test(value.classSHA256));return {errorClass:value.errorClass,classSHA256:value.classSHA256};}); + assert.equal(collector.errorClass,causeChain[0].errorClass); + return {phase:collector.phase,errorClass:collector.errorClass,causeChain,causeChainEnd:collector.causeChainEnd}; }catch{return;} } diff --git a/scripts/notices-runtime-root.test.groovy b/scripts/notices-runtime-root.test.groovy index 7f983217..79281cbb 100644 --- a/scripts/notices-runtime-root.test.groovy +++ b/scripts/notices-runtime-root.test.groovy @@ -5,9 +5,13 @@ import org.gradle.api.GradleException import org.codehaus.groovy.control.CompilerConfiguration import org.codehaus.groovy.control.customizers.ImportCustomizer import java.nio.file.Files +import java.security.MessageDigest import groovy.json.JsonSlurper class PrivateNoticeFailure extends RuntimeException { String getMessage() { throw new IllegalStateException('PRIVATE_GETTER') } } +class PrivateCauseFailure extends RuntimeException { int reads; Throwable getCause() { reads++; throw new IllegalStateException('PRIVATE_CAUSE_GETTER') } } +class LinkedNoticeFailure extends RuntimeException { int reads; Throwable next; Throwable getCause() { reads++; return next } } +class PrivateResolveFailure extends org.gradle.api.artifacts.ResolveException { PrivateResolveFailure(List causes) { super('PRIVATE_RESOLVE_MESSAGE', causes) } } assert args.length == 1 File source = new File(args[0]) @@ -47,14 +51,34 @@ try { } File build = new File(main.parentFile, 'build'), report = new File(build, 'notices/android/failure.json') def clear = { if (build.isDirectory()) build.deleteDir(); else build.delete() } + def entry = { Throwable item, String kind -> [errorClass: kind, classSHA256: MessageDigest.getInstance('SHA-256').digest(item.getClass().getName().getBytes('UTF-8')).encodeHex().toString()] } for (phase in ['root-binding', 'task-registration', 'runtime-resolution', 'artifact-collection']) { clear(); def original = phase == 'runtime-resolution' ? new IllegalStateException('PRIVATE_MESSAGE') : new PrivateNoticeFailure(); Throwable caught try { execute(main, main.path, true, [phase: phase, failure: original, diagnostics: true, runTask: true]) } catch (Throwable failure) { caught = failure } assert caught.is(original) def text = report.text, value = new JsonSlurper().parseText(text) - assert value == [schema: 1, status: 'INCOMPLETE', complete: false, sourceCommit: 'a' * 40, platform: 'android', stage: 'setup', setup: [phase: 'gradle-resolution', errorCategory: 'command-failed', collector: [phase: phase, errorClass: phase == 'runtime-resolution' ? 'IllegalStateException' : 'Throwable']]] + def kind = phase == 'runtime-resolution' ? 'IllegalStateException' : 'Throwable' + assert value == [schema: 1, status: 'INCOMPLETE', complete: false, sourceCommit: 'a' * 40, platform: 'android', stage: 'setup', setup: [phase: 'gradle-resolution', errorCategory: 'command-failed', collector: [phase: phase, errorClass: kind, causeChain: [entry(original, kind)], causeChainEnd: 'end']]] assert !text.contains('PRIVATE') && !text.contains(fixture.path) && !text.contains('stack') } + def inspect = { Throwable original, List expected, String end -> + clear(); Throwable caught + try { execute(main, main.path, true, [phase: 'runtime-resolution', failure: original, diagnostics: true, runTask: true]) } catch (Throwable failure) { caught = failure } + assert caught.is(original) + def text = report.text, collector = new JsonSlurper().parseText(text).setup.collector + assert collector == [phase: 'runtime-resolution', errorClass: expected[0].errorClass, causeChain: expected, causeChainEnd: end] + assert !text.contains('PRIVATE') && !text.contains(fixture.path) && !text.contains(original.getClass().getName()) && !text.contains('stack') + } + def leaf = new IOException('PRIVATE_URL'), nested = new IllegalStateException('PRIVATE_PATH', leaf), sibling = new PrivateCauseFailure() + def resolved = new PrivateResolveFailure([nested, sibling]) + assert resolved.getCause().is(nested) && resolved.getCauses().size() == 2 // Pinned Gradle exposes the first branch only. + inspect(resolved, [entry(resolved, 'ResolveException'), entry(nested, 'IllegalStateException'), entry(leaf, 'IOException')], 'end'); assert sibling.reads == 0 + def first = new LinkedNoticeFailure(), second = new LinkedNoticeFailure(); first.next = second; second.next = first + inspect(first, [entry(first, 'Throwable'), entry(second, 'Throwable')], 'cycle'); assert first.reads == 1 && second.reads == 1 + def unreadable = new PrivateCauseFailure(); inspect(unreadable, [entry(unreadable, 'Throwable')], 'unreadable'); assert unreadable.reads == 1 + def unknown = new PrivateNoticeFailure(); inspect(unknown, [entry(unknown, 'Throwable')], 'end') + def deep = (1..10).collect { new LinkedNoticeFailure() }; (0..8).each { deep[it].next = deep[it + 1] } + inspect(deep[0], deep.take(8).collect { entry(it, 'Throwable') }, 'limit'); assert deep.take(8).every { it.reads == 1 } && deep.drop(8).every { it.reads == 0 } for (blocked in ['file', 'symlink']) { clear(); if (blocked == 'file') build.text = 'PRIVATE_BLOCKER'; else Files.createSymbolicLink(build.toPath(), new File(fixture, 'outside').tap { mkdirs() }.toPath()) def original = new IllegalArgumentException('PRIVATE_ORIGINAL'); Throwable caught @@ -62,5 +86,5 @@ try { assert caught.is(original) && !report.exists() build.delete() } - println 'Actual init source: 7 root guards +4 finite Throwable phases +2 failed/outside writers PASS; no Gradle build.' + println 'Actual init source: 7 root guards +4 finite Throwable phases +5 bounded cause chains +2 failed/outside writers PASS; no Gradle build.' } finally { fixture.deleteDir() } diff --git a/scripts/notices-runtime.init.gradle b/scripts/notices-runtime.init.gradle index 7f18275e..7181f2c3 100644 --- a/scripts/notices-runtime.init.gradle +++ b/scripts/notices-runtime.init.gradle @@ -14,9 +14,17 @@ def recordNoticeFailure = { File mainRoot, String phase, Throwable failure -> if (mainRoot != new File(workspace, 'android').canonicalFile) return def output = new File(workspace, 'build/notices/android/failure.json') if (output.canonicalFile != output.absoluteFile) return - def kind = failure instanceof org.gradle.api.artifacts.ResolveException ? 'ResolveException' : failure instanceof GradleException ? 'GradleException' : failure instanceof IOException ? 'IOException' : failure instanceof IllegalArgumentException ? 'IllegalArgumentException' : failure instanceof IllegalStateException ? 'IllegalStateException' : failure instanceof SecurityException ? 'SecurityException' : failure instanceof Error ? 'Error' : 'Throwable' + def kind = { Throwable item -> item instanceof org.gradle.api.artifacts.ResolveException ? 'ResolveException' : item instanceof GradleException ? 'GradleException' : item instanceof IOException ? 'IOException' : item instanceof IllegalArgumentException ? 'IllegalArgumentException' : item instanceof IllegalStateException ? 'IllegalStateException' : item instanceof SecurityException ? 'SecurityException' : item instanceof Error ? 'Error' : 'Throwable' } + def chain = [], current = failure, end = 'end' + def seen = Collections.newSetFromMap(new IdentityHashMap()) + while (current != null) { + if (!seen.add(current)) { end = 'cycle'; break } + if (chain.size() == 8) { end = 'limit'; break } + chain.add([errorClass: kind(current), classSHA256: MessageDigest.getInstance('SHA-256').digest(current.getClass().getName().getBytes('UTF-8')).encodeHex().toString()]) + try { current = current.getCause() } catch (Throwable ignored) { end = 'unreadable'; break } + } output.parentFile.mkdirs() - output.text = JsonOutput.toJson([schema: 1, status: 'INCOMPLETE', complete: false, sourceCommit: source, platform: 'android', stage: 'setup', setup: [phase: 'gradle-resolution', errorCategory: 'command-failed', collector: [phase: phase, errorClass: kind]]]) + output.text = JsonOutput.toJson([schema: 1, status: 'INCOMPLETE', complete: false, sourceCommit: source, platform: 'android', stage: 'setup', setup: [phase: 'gradle-resolution', errorCategory: 'command-failed', collector: [phase: phase, errorClass: kind(failure), causeChain: chain, causeChainEnd: end]]]) } catch (Throwable ignored) { } } From 7994c3bd7b6972617c1b07a4b8aa733cbc57b5e4 Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 09:02:58 +0800 Subject: [PATCH 22/37] Bind generated pod inputs and finite artifact selection context --- docs/app-notices.md | 24 +++++++++++- scripts/app-notice-provenance.json | 29 +++++++++++++++ scripts/app-notices.mjs | 47 ++++++++++++++++++++++-- scripts/app-notices.test.mjs | 5 +++ scripts/capture-app-notices.mjs | 5 ++- scripts/native-notice-inputs.test.mjs | 43 +++++++++++++++++++++- scripts/notices-runtime-root.test.groovy | 45 ++++++++++++++++++++--- scripts/notices-runtime.init.gradle | 30 +++++++++++++-- 8 files changed, 212 insertions(+), 16 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index 78c93a56..8481f81b 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -87,6 +87,14 @@ exposes only the first cause through `getCause()`, so this does not enumerate al resolution failures or prove a root cause. It reads no raw class name into the report, exception message or stack and always rethrows the same Throwable; a failed/outside-workspace metadata write is ignored. +Only the observed exact Gradle 9.3.1 `ArtifactSelectionException` can add optional +selection metadata: failure-class SHA, a fixed module/main-project kind and a +component SHA. Module identity is `maven:group:module@version`; main-project +identity is `gradle-project:` plus its build path, `:` and project path, after +checking the actual main root/project binding. Getters are contained; unknown, +unreadable or competing leaves omit this context. No display name, attribute, +variant, message or raw coordinate is emitted. This identifies a failure family +and component fingerprint, not a successful resolution or a demonstrated cause. The shell writer retains this context only after exact schema, source and enum validation, then reconstructs the finite report. Failures before these callbacks or during other Gradle/plugin configuration still have only the generic setup @@ -97,7 +105,7 @@ This uses Gradle's public [root project](https://docs.gradle.org/current/javadoc and [project directory](https://docs.gradle.org/current/javadoc/org/gradle/api/Project.html#getProjectDir()) APIs. The source-only root guard regression executes this actual init script through `groovy.ui.GroovyMain scripts/notices-runtime-root.test.groovy scripts/notices-runtime.init.gradle` -with the installed Gradle distribution's `lib/*` on the Java classpath. It stubs +with the installed Gradle distribution's `lib/*` and `lib/plugins/*` on the Java classpath. It stubs Gradle callbacks/task registration only; it starts no build or resolver. Reports expose @@ -203,6 +211,20 @@ walks Pods and autolinked Gradle native subtrees, fingerprints source files, and finds source-only `vendor`/`third_party` children as well as binary vendors. Native roots are restricted to installed packages, Pods and identified local modules; arbitrary project data is not traversed. +The two literal RN 0.86.3 roots `ReactAppDependencyProvider` and `ReactCodegen` +under `ios/build/generated/ios/` also require their exact Podfile.lock declaration, +locked producer package/config/template hashes and selected input fingerprints. +The Provider must contain exactly its three expected template-derived files. +ReactCodegen requires actual autolinking JSON with the pinned nine dependency +configs, seven configured spec-source trees and platform podspecs, no app-level +codegen config or native component crawler, plus separate RN core/spec evidence. +A test invokes the pinned selector to prove this selection path; no missing +metadata is replaced with guessed dependency discovery. Raw autolinking bodies +and absolute roots stay private; only logical package/version identities and +hashes enter reports. Unknown roots, changed pins and escaping links reject. +All output bytes are fingerprinted, and each input owner remains a separate +vendor boundary needing explicit notice review; admitting a generated source +does not inherit a producer's licence or complete the empty approval ledger. Only the actual Gradle build-directory output, configured CMake staging path and Git metadata are excluded. The metadata exporter reads AGP's CMake configuration; its module-local `.cxx` default applies only when CMake is enabled. Canonical diff --git a/scripts/app-notice-provenance.json b/scripts/app-notice-provenance.json index f07b86a2..74fc01d1 100644 --- a/scripts/app-notice-provenance.json +++ b/scripts/app-notice-provenance.json @@ -19,6 +19,35 @@ "component": "npm:@expo/cli@57.0.26" } }, + "generatedPods": { + "version": "0.86.3", + "producerPackages": [ + {"component":"npm:react-native@0.86.3","packagePath":"node_modules/react-native","packageJSONSHA256":"416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144"}, + {"component":"npm:@react-native/codegen@0.86.3","packagePath":"node_modules/@react-native/codegen","packageJSONSHA256":"3a6b22f59979fe504ec41caf22461865406e83e3eccf4788097377e5e02846c9"} + ], + "producerFiles": [ + {"source":"node_modules/react-native/scripts/generate-codegen-artifacts.js","sha256":"5d6386b6b00960a72711d9616b3f72b3f08cb4e1ae591b4a74ac2bb8e51fc91d"}, + {"source":"node_modules/react-native/scripts/cocoapods/codegen_utils.rb","sha256":"438fcff096c2d69bd0e1254412e22cbe04609b9d20ad709001e41f8c5333e0b7"}, + {"source":"node_modules/react-native/scripts/react_native_pods.rb","sha256":"3415dc14a93f59ec61fb83976d99c2fb6e0265ff80f6fc1ee4c07a5b127fd4c8"}, + {"source":"node_modules/react-native/scripts/cocoapods/codegen.rb","sha256":"ef5295dde034fbf335e90628769bd6f36093aa29f050eaafca43423acf23e1f0"} + ], + "producerTrees": [ + {"source":"node_modules/react-native/scripts/codegen","files":28,"sha256":"95be210dc48143a1684f0e89d4a385e31602a42317b6e2ca7301b12312a9ce4c"}, + {"source":"node_modules/@react-native/codegen/lib","files":171,"sha256":"1f78538018229e10fd0d09d42d9bdfe62a9511827c884ef5a11d7281fcc3c9c9"} + ], + "coreSourceTrees": [{"source":"node_modules/react-native/src","sha256":"85e1e0f7113b29ede48aee9e84a9eaff0c9cafaf65691b75d11f0e61c735a768","files":188},{"source":"node_modules/react-native/React/FBReactNativeSpec","sha256":"d7012919fba286bea5bc5b00506e97b9666f44d9566eab2b9597038a84359fee","files":14}], + "libraries": [ + {"component":"npm:@react-native-async-storage/async-storage@2.2.0","packagePath":"node_modules/@react-native-async-storage/async-storage","packageJSONSHA256":"6346a7c16602136ed46fc0aadd02c388ab160540dc32c1c5ad877d7152750d30","codegen":true,"source":"node_modules/@react-native-async-storage/async-storage/src","sourceFingerprint":{"sha256":"109f0e2274334ea1b1a438f64afb353ab01fcf79faee48eac7685f65e7f4ee2f","files":9},"podspecs":[{"source":"node_modules/@react-native-async-storage/async-storage/RNCAsyncStorage.podspec","sha256":"c2f33c853d9a40318299e76c040e0f7d349edc0d6983857ad7dad1506cf56281"}]}, + {"component":"npm:@react-native-google-signin/google-signin@16.1.5","packagePath":"node_modules/@react-native-google-signin/google-signin","packageJSONSHA256":"8007e4f26f1b1a34a1621f713d8d4bb4a7170f2d976299e14721b74068989727","codegen":true,"source":"node_modules/@react-native-google-signin/google-signin/src/spec","sourceFingerprint":{"sha256":"7eaa1b008ed562bbfe6623786b20f239937edb84314aa7e11d512c4866708343","files":2},"podspecs":[{"source":"node_modules/@react-native-google-signin/google-signin/RNGoogleSignin.podspec","sha256":"50e4c8ec91bb448f4cb5d7dd291b0baea940607a5d3cb6230a96958cb9bb6493"}]}, + {"component":"npm:@react-native-masked-view/masked-view@0.3.2","packagePath":"node_modules/@react-native-masked-view/masked-view","packageJSONSHA256":"0ba91122588091da58e02f3b58ed093ba7710e0166f7e829a4db403cac8539cd","codegen":false}, + {"component":"npm:expo@57.0.24","packagePath":"node_modules/expo","packageJSONSHA256":"d01199b51f5f9beca73e371e1893a360a0017fe8cbdc722a5f3ca86aee5d1273","codegen":false}, + {"component":"npm:react-native-gesture-handler@2.32.0","packagePath":"node_modules/react-native-gesture-handler","packageJSONSHA256":"837b814bfafd97dcd2f3a50a55e7cffb2dbf06a55e6b449bc52959c0e75c6e58","codegen":true,"source":"node_modules/react-native-gesture-handler/src/specs","sourceFingerprint":{"sha256":"36b9361a5cfcf0d7397fae96e43fe2c74ec9b3714e7bbad892b79e87ba28719d","files":3},"podspecs":[{"source":"node_modules/react-native-gesture-handler/RNGestureHandler.podspec","sha256":"de7206bf70e4ed764b0e1a8ca666ab0c9c7b6927351f6fabadab922d65c4b7e5"}]}, + {"component":"npm:react-native-reanimated@4.5.1","packagePath":"node_modules/react-native-reanimated","packageJSONSHA256":"bc85351a040fce75f2e42ba96dea2e3cc3e29f65c7a89ce4eaae893b11c1bece","codegen":true,"source":"node_modules/react-native-reanimated/src/specs","sourceFingerprint":{"sha256":"ec32459dfb1694ad0d63fbda1e0e42cecce64a11d4e8c689a89199fea1d41fc1","files":5},"podspecs":[{"source":"node_modules/react-native-reanimated/RNReanimated.podspec","sha256":"93d2d5148b78e7bd0f99588cb940081f32bafce53545debac97a1a5f7103f09d"}]}, + {"component":"npm:react-native-safe-area-context@5.7.0","packagePath":"node_modules/react-native-safe-area-context","packageJSONSHA256":"752ca40cc831855a26fb9eda18ff6bad54986835d9aa42bba1129d53857f6131","codegen":true,"source":"node_modules/react-native-safe-area-context/src/specs","sourceFingerprint":{"sha256":"d659b81eb1e2a578a6926edd4ebe32756e844e548d90b3837acb2250c6377f03","files":3},"podspecs":[{"source":"node_modules/react-native-safe-area-context/react-native-safe-area-context.podspec","sha256":"0fb3d1f4f44273c9b2ac59f05e5e18220e60cee7152fc0da5bb71be0b29afffa"}]}, + {"component":"npm:react-native-screens@4.26.2","packagePath":"node_modules/react-native-screens","packageJSONSHA256":"31107f4c0ad2e0902b5da521bd5b04060ebb462d474a7becdb5b2739f90bde0c","codegen":true,"source":"node_modules/react-native-screens/src/fabric","sourceFingerprint":{"sha256":"83e60150fed6fe12d75b4ccb7ec2c8f4dfd51adab875356c84f1e32539630b97","files":33},"podspecs":[{"source":"node_modules/react-native-screens/RNScreens.podspec","sha256":"3cfd1157e743f67e5b9a634faca6566a5a930cd4b44062d8eefebf74f2509b03"}]}, + {"component":"npm:react-native-worklets@0.10.1","packagePath":"node_modules/react-native-worklets","packageJSONSHA256":"485883959fd6a9d6981fbf2dc9607aaf6c9d8ce380c5359f0dd82f27fda95628","codegen":true,"source":"node_modules/react-native-worklets/src/specs","sourceFingerprint":{"sha256":"8c89a56371729773980629b107fab4af16fc55603e33f908177700ed71384e3a","files":2},"podspecs":[{"source":"node_modules/react-native-worklets/RNWorklets.podspec","sha256":"a4c9da94d93668b3f260a578933a685e8154a3f219e47523919c7972aa887c75"}]} + ] + }, "packagedNative": {"ios": {}, "android": {}}, "knownLicenseSources": [ {"component":"npm:react-native@0.86.3","ecosystem":"npm","version":"0.86.3","public":true,"source":"node_modules/react-native/LICENSE","sha256":"da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93","upstream":"https://github.com/react/react-native/blob/v0.86.3/LICENSE"}, diff --git a/scripts/app-notices.mjs b/scripts/app-notices.mjs index 9b640080..0abf3780 100644 --- a/scripts/app-notices.mjs +++ b/scripts/app-notices.mjs @@ -4,6 +4,7 @@ import {createHash} from 'node:crypto'; import {closeSync,existsSync,openSync,readFileSync,readSync,readdirSync,realpathSync,statSync,lstatSync} from 'node:fs'; import {dirname,isAbsolute,join,relative,resolve,sep} from 'node:path'; import {fileURLToPath} from 'node:url'; +import {createRequire} from 'node:module'; import {isDeepStrictEqual} from 'node:util'; export const digest=bytes=>createHash('sha256').update(bytes).digest('hex'); @@ -12,6 +13,7 @@ const inside=(root,path)=>path===root||path.startsWith(root+sep); const noticeName=/^(licen[cs]es?|notices?|copying|copyright|third[._-]?party[._-]?(?:licen[cs]es?|notices?))([._-].*)?$/i; const vendorName=/^(vendor|vendored|third[-_]?party)$/i; const maximumTextBytes=1024*1024; +const require=createRequire(import.meta.url); export function safeRelative(value){ assert.equal(typeof value,'string');assert.ok(value&&!isAbsolute(value)&&!/^\w:[\\/]/.test(value)&&!value.split(/[\\/]/).includes('..')); return value.replaceAll('\\','/'); @@ -40,7 +42,8 @@ function nearbyNotices(boundary,start,prefix){ // through the actual project build-directory metadata, never a vendor wildcard. export function nativeSourceNotices(root,supplied,prefix,{excluded=[],linkedSources=[]}={}){ const boundary=realpathSync(supplied),project=realpathSync(root),modules=existsSync(join(root,'node_modules'))?realpathSync(join(root,'node_modules')):null; - assert.ok((modules&&inside(modules,boundary))||boundary.startsWith(join(project,'ios/Pods')+sep)||boundary.startsWith(join(project,'modules')+sep),'Native source must be an identified package/Pod/local module, not arbitrary project data.'); + const generated=generatedPodInputs(root,supplied,prefix); + assert.ok(generated||(modules&&inside(modules,boundary))||boundary.startsWith(join(project,'ios/Pods')+sep)||boundary.startsWith(join(project,'modules')+sep),'Native source must be an identified package/Pod/local module, not arbitrary project data.'); const canonicalFuture=path=>{path=resolve(path);let ancestor=path;while(!existsSync(ancestor))ancestor=dirname(ancestor);return resolve(realpathSync(ancestor),relative(ancestor,path));}; const links=new Map(); for(const value of linkedSources){ @@ -51,7 +54,7 @@ export function nativeSourceNotices(root,supplied,prefix,{excluded=[],linkedSour assert.ok(inside(packageRoot,target)&&realpathSync(link)===target&&statSync(target).isDirectory(),'Native source link escapes its component.'); links.set(name,{source:name,target:targetName,packagePath:owner.packagePath,component:owner.id,targetRoot:target}); } - const omitted=new Set(excluded.map(canonicalFuture)),files=[],notices=[],vendors=new Set(),visited=new Set();let count=0,total=0; + const omitted=new Set(excluded.map(canonicalFuture)),files=[],notices=[...(generated?.notices??[])],vendors=new Set(generated?.vendors??[]),visited=new Set();let count=0,total=0; function walk(folder,logical='',scope=boundary){ const canonical=realpathSync(folder);assert.ok(inside(scope,canonical),'Native source link escapes its component.'); if(visited.has(canonical))return;visited.add(canonical); @@ -71,7 +74,45 @@ export function nativeSourceNotices(root,supplied,prefix,{excluded=[],linkedSour } walk(boundary); const vendorNotices=Object.fromEntries([...vendors].sort().map(value=>[value,notices.filter(item=>item.source.startsWith(prefix+'/'+value+'/')).map(item=>item.source)])); - return {notices,vendors:[...vendors].sort(),vendorNotices,nativeInputs:{[prefix]:{sha256:digest(JSON.stringify(files)),files:files.length,excludedBuildPaths:[...omitted].filter(path=>inside(boundary,path)).map(path=>safeRelative(relative(boundary,path))).sort(),...(links.size?{linkedSources:[...links.values()].map(({targetRoot,...value})=>value).sort((a,b)=>a.source.localeCompare(b.source))}:{})}}}; + return {notices,vendors:[...vendors].sort(),vendorNotices,nativeInputs:{[prefix]:{sha256:digest(JSON.stringify(files)),files:files.length,excludedBuildPaths:[...omitted].filter(path=>inside(boundary,path)).map(path=>safeRelative(relative(boundary,path))).sort(),...(generated?{generated:generated.inputs}:{}),...(links.size?{linkedSources:[...links.values()].map(({targetRoot,...value})=>value).sort((a,b)=>a.source.localeCompare(b.source))}:{})}}}; +} + +// These two selected RN-generated Pods are not a general build-directory root. +// Admission proves their locked producers/selected source closure, not licences. +export function generatedPodInputs(root,supplied,prefix){ + const name=prefix.replace(/^cocoapods:/,'');if(!['ReactAppDependencyProvider','ReactCodegen'].includes(name)||prefix!=='cocoapods:'+name)return; + root=realpathSync(root);const project=root,source='ios/build/generated/ios/'+name,expected=join(project,source),pins=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))).generatedPods; + assert.equal(pins.version,'0.86.3');assert.equal(realpathSync(supplied),expected);assert.equal(realpathSync(expected),expected); + const lock=require('yaml').parse(noticeText(readFileSync(join(root,'ios/Podfile.lock')),16*maximumTextBytes)); + assert.ok(lock.PODS.some(value=>(typeof value==='string'?value:Object.keys(value)[0]).split(' (')[0]===name&&new RegExp('^'+name+' \\(0\\.86\\.3\\)').test(typeof value==='string'?value:Object.keys(value)[0])));assert.deepEqual(lock['EXTERNAL SOURCES'][name],{':path':'build/generated/ios/'+name}); + const notices=[],owners=[]; + assert.deepEqual(pins.producerPackages.map(value=>value.component).sort(),['npm:@react-native/codegen@0.86.3','npm:react-native@0.86.3']); + const pinnedPackage=value=>{const path=safeRelative(value.packagePath),pkg=JSON.parse(readFileSync(join(root,path,'package.json'))),item=npmNoticeInputs(root,[join(root,path,'package.json')])[0];assert.equal(item?.id,value.component);assert.equal(item.packagePath,path);assert.equal(digest(readFileSync(join(root,path,'package.json'))),value.packageJSONSHA256);notices.push(...item.notices);owners.push(value.component);return pkg;}; + for(const value of pins.producerPackages)pinnedPackage(value); + const files=['node_modules/react-native/scripts/generate-codegen-artifacts.js','node_modules/react-native/scripts/cocoapods/codegen_utils.rb','node_modules/react-native/scripts/react_native_pods.rb','node_modules/react-native/scripts/cocoapods/codegen.rb']; + assert.deepEqual(pins.producerFiles.map(value=>value.source),files); + for(const value of pins.producerFiles){const actual=capturedSource(root,value.source);assert.equal(actual.component,'npm:react-native@0.86.3');assert.equal(actual.sha256,value.sha256);} + assert.deepEqual(pins.producerTrees.map(value=>value.source),['node_modules/react-native/scripts/codegen','node_modules/@react-native/codegen/lib']); + assert.deepEqual(pins.coreSourceTrees.map(value=>value.source),['node_modules/react-native/src','node_modules/react-native/React/FBReactNativeSpec']); + for(const value of [...pins.producerTrees,...pins.coreSourceTrees]){const tree=nativeSourceNotices(root,join(root,value.source),'generator-input');assert.equal(tree.nativeInputs['generator-input'].sha256,value.sha256);assert.equal(tree.nativeInputs['generator-input'].files,value.files);assert.equal(tree.vendors.length,0,'Generated input vendor boundary needs separate provenance.');} + const metaPath='ios/build/generated/autolinking/autolinking.json',bytes=readFileSync(join(root,metaPath)),meta=JSON.parse(noticeText(bytes,16*maximumTextBytes)); + assert.equal(realpathSync(meta.root),project);assert.equal(realpathSync(meta.reactNativePath),realpathSync(join(root,'node_modules/react-native')));assert.equal(JSON.parse(readFileSync(join(root,'package.json'))).codegenConfig,undefined); + assert.equal(pins.libraries.length,9);assert.deepEqual(Object.keys(meta.dependencies).sort(),pins.libraries.map(value=>JSON.parse(readFileSync(join(root,value.packagePath,'package.json'))).name).sort()); + const libraries=[]; + for(const value of pins.libraries){const pkg=pinnedPackage(value),config=meta.dependencies[pkg.name];assert.equal(realpathSync(config.root),realpathSync(join(root,value.packagePath)));assert.notEqual(config.platforms?.ios,null);assert.equal(!!pkg.codegenConfig,value.codegen); + const record={component:value.component,packagePath:value.packagePath,packageJSONSHA256:value.packageJSONSHA256,codegen:value.codegen}; + if(value.codegen){assert.ok(!pkg.codegenConfig.libraries);const path=resolve(root,value.packagePath,safeRelative(pkg.codegenConfig.jsSrcsDir));assert.ok(inside(realpathSync(join(root,value.packagePath)),realpathSync(path)));assert.equal(safeRelative(relative(project,path)),value.source);const scan=nativeSourceNotices(root,path,'codegen-input'),tree=scan.nativeInputs['codegen-input'];assert.equal(tree.sha256,value.sourceFingerprint.sha256);assert.equal(tree.files,value.sourceFingerprint.files);assert.equal(scan.vendors.length,0,'Generated input vendor boundary needs separate provenance.');record.source=value.source;record.sourceFingerprint={sha256:tree.sha256,files:tree.files}; + assert.deepEqual(readdirSync(join(root,value.packagePath)).filter(value=>value.endsWith('.podspec')).sort(),value.podspecs.map(value=>value.source.slice(value.source.lastIndexOf('/')+1)));record.podspecs=value.podspecs.map(value=>{const actual=capturedSource(root,value.source);assert.equal(actual.component,record.component);assert.equal(actual.sha256,value.sha256);return {source:actual.path,sha256:actual.sha256};}); + const ios=pkg.codegenConfig.ios;if(pkg.codegenConfig.type!=='modules')assert.ok((ios?.componentProvider&&!ios.components)||(ios?.components&&Object.keys(ios.components).length&&Object.values(ios.components).every(value=>typeof value.className==='string'&&value.className)),'Unproven codegen native component crawl.'); + }libraries.push(record); + } + if(name==='ReactAppDependencyProvider'){ + const outputs={'RCTAppDependencyProvider.h':'RCTAppDependencyProviderH.template','RCTAppDependencyProvider.mm':'RCTAppDependencyProviderMM.template','ReactAppDependencyProvider.podspec':'ReactAppDependencyProvider.podspec.template'};assert.deepEqual(readdirSync(expected).sort(),Object.keys(outputs).sort()); + for(const [output,template] of Object.entries(outputs)){let bytes=readFileSync(join(root,'node_modules/react-native/scripts/codegen/templates',template));if(output.endsWith('.podspec'))bytes=Buffer.from(bytes.toString('utf8').replace('{react-native-version}','0.86.3').replace('{react-native-licence}','MIT'));assert.ok(lstatSync(join(expected,output)).isFile());assert.ok(bytes.equals(readFileSync(join(expected,output))));} + }else{ + for(const output of ['ReactCodegen.podspec',...['RCTThirdPartyComponentsProvider','RCTModuleProviders','RCTModulesConformingToProtocolsProvider','RCTUnstableModulesRequiringMainQueueSetupProvider'].flatMap(value=>[value+'.h',value+'.mm'])])assert.ok(lstatSync(join(expected,output)).isFile()); + } + return {notices:[...new Map(notices.map(value=>[value.source,value])).values()],vendors:[...new Set(owners)].sort().map(value=>'generated-input:'+value),inputs:{source,producerPackages:pins.producerPackages.map(({component,packagePath,packageJSONSHA256})=>({component,packagePath,packageJSONSHA256})),producerFiles:pins.producerFiles.map(({source,sha256})=>({source,sha256})),producerTrees:pins.producerTrees.map(({source,sha256,files})=>({source,sha256,files})),coreSourceTrees:pins.coreSourceTrees.map(({source,sha256,files})=>({source,sha256,files})),autolinking:{source:metaPath,sha256:digest(bytes)},appCodegenConfigAbsent:true,libraries}}; } // Use the actual Metro-selected files, not all installed/declared dependencies. diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs index eedd1d53..b804cfdd 100644 --- a/scripts/app-notices.test.mjs +++ b/scripts/app-notices.test.mjs @@ -293,6 +293,11 @@ test('setup failure CLI retains only matching finite Gradle collector context an file(path,{...known,setup:{...known.setup,collector:{...collector,...bad}}});const result=cli('gradle-resolution');assert.equal(result.status,0);assert.deepEqual(JSON.parse(readFileSync(join(root,path))),generic);assert.ok(!(readFileSync(join(root,path),'utf8')+result.stdout+result.stderr).includes('PRIVATE')); } for(const end of ['end','cycle','unreadable','limit']){const causes=end==='limit'?Array(8).fill(chain[0]):chain;const value={...known,setup:{...known.setup,collector:{...collector,causeChain:causes,causeChainEnd:end}}};file(path,value);assert.equal(cli('gradle-resolution').status,0);assert.deepEqual(JSON.parse(readFileSync(join(root,path))),value);} + const leaf={errorClass:'GradleException',classSHA256:'2a5335183636baba800ed56814f24536518c33cbd68932ba9a3fd10004d2b5fd'},selection={failureClassSHA256:digest('PRIVATE_FAILURE_CLASS'),componentKind:'module',componentSHA256:digest('PRIVATE_COMPONENT')}; + for(const componentKind of ['module','main-project']){const value={...known,setup:{...known.setup,collector:{...collector,causeChain:[chain[0],leaf],selection:{...selection,componentKind}}}};file(path,value);assert.equal(cli('gradle-resolution').status,0);assert.deepEqual(JSON.parse(readFileSync(join(root,path))),value);} + for(const bad of [{...selection,message:'PRIVATE_BODY'},{...selection,componentKind:'PRIVATE_KIND'},{...selection,componentSHA256:'PRIVATE_COMPONENT'},{...selection,failureClassSHA256:[selection.failureClassSHA256]},null]){file(path,{...known,setup:{...known.setup,collector:{...collector,causeChain:[chain[0],leaf],selection:bad}}});assert.equal(cli('gradle-resolution').status,0);assert.deepEqual(JSON.parse(readFileSync(join(root,path))),generic);} + file(path,{...known,setup:{...known.setup,collector:{...collector,selection}}});assert.equal(cli('gradle-resolution').status,0);assert.deepEqual(JSON.parse(readFileSync(join(root,path))),generic,'An unknown leaf cannot attach selector context.'); + file(path,{...known,setup:{...known.setup,collector:{...collector,causeChain:[chain[0],leaf,leaf],selection}}});assert.equal(cli('gradle-resolution').status,0);assert.deepEqual(JSON.parse(readFileSync(join(root,path))),generic,'Competing selection leaves cannot attach ambiguous context.'); file(path,known);assert.equal(cli('prebuild').status,0);assert.deepEqual(JSON.parse(readFileSync(join(root,path))),{...generic,setup:{phase:'prebuild',errorCategory:'command-failed'}}); })); diff --git a/scripts/capture-app-notices.mjs b/scripts/capture-app-notices.mjs index 70b68b92..731f995a 100644 --- a/scripts/capture-app-notices.mjs +++ b/scripts/capture-app-notices.mjs @@ -120,11 +120,12 @@ function retainedCollectorFailure(path,sourceCommit,phase){ assert.equal(value.schema,1);assert.equal(value.status,'INCOMPLETE');assert.equal(value.complete,false);assert.equal(value.sourceCommit,sourceCommit);assert.equal(value.platform,'android');assert.equal(value.stage,'setup'); assert.deepEqual(Object.keys(setup).sort(),['phase','errorCategory','collector'].sort());assert.equal(setup.phase,phase);assert.equal(setup.errorCategory,'command-failed'); const classes=['ResolveException','GradleException','IOException','IllegalArgumentException','IllegalStateException','SecurityException','Error','Throwable']; - assert.deepEqual(Object.keys(collector).sort(),['phase','errorClass','causeChain','causeChainEnd'].sort());assert.ok(['root-binding','task-registration','runtime-resolution','artifact-collection'].includes(collector.phase));assert.ok(classes.includes(collector.errorClass)); + assert.deepEqual(Object.keys(collector).sort(),['phase','errorClass','causeChain','causeChainEnd',...(Object.hasOwn(collector,'selection')?['selection']:[])].sort());assert.ok(['root-binding','task-registration','runtime-resolution','artifact-collection'].includes(collector.phase));assert.ok(classes.includes(collector.errorClass)); assert.ok(Array.isArray(collector.causeChain)&&collector.causeChain.length>=1&&collector.causeChain.length<=8);assert.ok(['end','limit','cycle','unreadable'].includes(collector.causeChainEnd));assert.ok(collector.causeChainEnd!=='limit'||collector.causeChain.length===8); const causeChain=collector.causeChain.map(value=>{assert.deepEqual(Object.keys(value).sort(),['errorClass','classSHA256'].sort());assert.ok(classes.includes(value.errorClass)&&typeof value.classSHA256==='string'&&/^[a-f0-9]{64}$/.test(value.classSHA256));return {errorClass:value.errorClass,classSHA256:value.classSHA256};}); assert.equal(collector.errorClass,causeChain[0].errorClass); - return {phase:collector.phase,errorClass:collector.errorClass,causeChain,causeChainEnd:collector.causeChainEnd}; + let selection;if(Object.hasOwn(collector,'selection')){const value=collector.selection;assert.deepEqual(Object.keys(value).sort(),['failureClassSHA256','componentKind','componentSHA256'].sort());assert.equal(causeChain.filter(value=>value.classSHA256==='2a5335183636baba800ed56814f24536518c33cbd68932ba9a3fd10004d2b5fd').length,1);assert.ok(['module','main-project'].includes(value.componentKind));for(const hash of [value.failureClassSHA256,value.componentSHA256])assert.ok(typeof hash==='string'&&/^[a-f0-9]{64}$/.test(hash));selection={failureClassSHA256:value.failureClassSHA256,componentKind:value.componentKind,componentSHA256:value.componentSHA256};} + return {phase:collector.phase,errorClass:collector.errorClass,causeChain,causeChainEnd:collector.causeChainEnd,...(selection?{selection}:{})}; }catch{return;} } diff --git a/scripts/native-notice-inputs.test.mjs b/scripts/native-notice-inputs.test.mjs index 59725b4b..9131f466 100644 --- a/scripts/native-notice-inputs.test.mjs +++ b/scripts/native-notice-inputs.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import {execFileSync} from 'node:child_process'; -import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,rmSync,symlinkSync} from 'node:fs'; +import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,rmSync,symlinkSync,cpSync,readdirSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {createRequire} from 'node:module'; @@ -203,3 +203,44 @@ test('Pods follow only exact selected frameworks into the locked podspec package file(pkg+'/package.json',{name:'qa-sdk',version:'2'});assert.throws(()=>podNoticeInputs(root),'An installed owner differing from package-lock cannot qualify the link.');file(pkg+'/package.json',{name:'qa-sdk',version:'1'}); file(pkg+'/outside/NOTICE','Not inside the selected vendor root');symlinkSync(join(root,pkg,'outside'),join(root,target,'outside'));assert.throws(()=>podNoticeInputs(root),'Nested links cannot escape even into another part of the owner package.'); })); + +test('only the two locked generated Pods admit their actual autolink/config/spec closure, without inheriting licences',()=>fixture(({root,file})=>{ + const pins=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))).generatedPods,packages=[...pins.producerPackages,...pins.libraries],unique=[...new Map(packages.map(value=>[value.packagePath,value])).values()]; + file('package.json',{name:'notice-fixture',version:'1'});file('package-lock.json',{packages:Object.fromEntries(unique.map(value=>[value.packagePath,{version:value.component.slice(value.component.lastIndexOf('@')+1)}]))}); + for(const value of unique){file(value.packagePath+'/package.json',readFileSync(value.packagePath+'/package.json'));for(const notice of npmNoticeInputs(process.cwd(),[join(process.cwd(),value.packagePath,'package.json')])[0].notices)file(notice.source,notice.body);} + for(const value of pins.producerFiles)file(value.source,readFileSync(value.source)); + for(const value of [...pins.producerTrees,...pins.coreSourceTrees,...pins.libraries.filter(value=>value.codegen)]){const target=value.source;mkdirSync(join(root,target),{recursive:true});cpSync(target,join(root,target),{recursive:true});for(const spec of value.podspecs??[])file(spec.source,readFileSync(spec.source));} + const names=['ReactAppDependencyProvider','ReactCodegen'],lock={PODS:names.map(name=>name+' (0.86.3)'),'EXTERNAL SOURCES':Object.fromEntries(names.map(name=>[name,{':path':'build/generated/ios/'+name}]))};file('ios/Podfile.lock',lock);mkdirSync(join(root,'ios/Pods/Target Support Files'),{recursive:true}); + const metadata={root,reactNativePath:join(root,'node_modules/react-native'),dependencies:Object.fromEntries(pins.libraries.map(value=>[JSON.parse(readFileSync(value.packagePath+'/package.json')).name,{root:join(root,value.packagePath),platforms:{ios:{}}}])),project:{privateBody:'PRIVATE_EMAIL_AND_PATH'}};const metaPath='ios/build/generated/autolinking/autolinking.json';file(metaPath,metadata); + const provider='ios/build/generated/ios/ReactAppDependencyProvider',codegen='ios/build/generated/ios/ReactCodegen'; + for(const [name,template] of Object.entries({'RCTAppDependencyProvider.h':'RCTAppDependencyProviderH.template','RCTAppDependencyProvider.mm':'RCTAppDependencyProviderMM.template','ReactAppDependencyProvider.podspec':'ReactAppDependencyProvider.podspec.template'})){let bytes=readFileSync('node_modules/react-native/scripts/codegen/templates/'+template);if(name.endsWith('.podspec'))bytes=Buffer.from(bytes.toString().replace('{react-native-version}','0.86.3').replace('{react-native-licence}','MIT'));file(provider+'/'+name,bytes);} + for(const name of ['ReactCodegen.podspec',...['RCTThirdPartyComponentsProvider','RCTModuleProviders','RCTModulesConformingToProtocolsProvider','RCTUnstableModulesRequiringMainQueueSetupProvider'].flatMap(value=>[value+'.h',value+'.mm'])])file(codegen+'/'+name,'Synthetic generated output; no native execution or licence approval.\n'); + const capture=()=>podNoticeInputs(root).components,initial=capture();assert.equal(initial.length,2); + for(const item of initial){const value=item.nativeInputs['cocoapods:'+item.name].generated;assert.equal(value.libraries.length,9);assert.equal(value.libraries.filter(value=>value.codegen).length,7);assert.equal(resolveNoticeCoverage([item],approval(item)).unresolved[0].reason,'missing-vendored-notice');assert.ok(!JSON.stringify(item).includes(root)&&!JSON.stringify(item).includes('PRIVATE')); + if(item.name==='ReactCodegen'){file(codegen+'/RCTModuleProviders.h','Changed synthetic generated output');assert.notEqual(capture().find(v=>v.name===item.name).nativeInputs['cocoapods:ReactCodegen'].sha256,item.nativeInputs['cocoapods:ReactCodegen'].sha256);file(codegen+'/RCTModuleProviders.h','Synthetic generated output; no native execution or licence approval.\n');} + } + const rejects=()=>assert.throws(capture),mutate=(path,bytes)=>{const before=readFileSync(join(root,path));file(path,bytes);rejects();file(path,before);}; + mutate(pins.producerFiles[0].source,'Changed generator bytes');mutate('node_modules/react-native/scripts/codegen/generate-artifacts-executor/index.js','Changed dispatcher'); + const core=pins.coreSourceTrees[0].source,coreEntry=readdirSync(join(root,core),{recursive:true,withFileTypes:true}).find(value=>value.isFile());mutate(join(coreEntry.parentPath??coreEntry.path,coreEntry.name).slice(root.length+1),'Changed React Native core spec source'); + mutate(pins.libraries.find(value=>value.codegen).podspecs[0].source,'Changed platform-selection podspec'); + const source=pins.libraries.find(value=>value.codegen).source,entry=readdirSync(join(root,source),{recursive:true,withFileTypes:true}).find(value=>value.isFile());mutate(join(entry.parentPath??entry.path,entry.name).slice(root.length+1),'Changed selected spec input'); + mutate(provider+'/RCTAppDependencyProvider.h','Modified provider');file(provider+'/unknown-output.h','unknown');rejects();rmSync(join(root,provider+'/unknown-output.h')); + const metaClone=()=>JSON.parse(JSON.stringify(metadata)); + for(const change of [v=>delete v.dependencies.expo,v=>v.dependencies.unknown={root},v=>v.dependencies.expo.platforms.ios=null,v=>v.dependencies.expo.root=root,v=>v.root=join(root,'elsewhere')]){const v=metaClone();change(v);file(metaPath,v);rejects();}file(metaPath,metadata); + file('package.json',{name:'notice-fixture',codegenConfig:{jsSrcsDir:'src'}});rejects();file('package.json',{name:'notice-fixture',version:'1'}); + const version=JSON.parse(JSON.stringify(lock));version.PODS[0]='ReactAppDependencyProvider (0.86.4)';file('ios/Podfile.lock',version);rejects();file('ios/Podfile.lock',lock); + mutate('node_modules/react-native/package.json',JSON.stringify({...JSON.parse(readFileSync('node_modules/react-native/package.json')),version:'0.86.4'})); + file('outside/private','PRIVATE_SOURCE');symlinkSync(join(root,'outside'),join(root,codegen,'escaping'));rejects();rmSync(join(root,codegen,'escaping')); + assert.throws(()=>nativeSourceNotices(root,join(root,codegen),'cocoapods:UnknownGenerated')); + assert.throws(()=>nativeSourceNotices(root,join(root,provider),'cocoapods:ReactCodegen')); + const absent=join(root,metaPath);rmSync(absent);rejects();file(metaPath,metadata);assert.equal(capture().length,2); +})); + +test('the pinned RN selector uses actual autolinking metadata, with app config absent and core evidence kept separate',()=>fixture(({root,file})=>{ + const pins=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))).generatedPods,utils=require('react-native/scripts/codegen/generate-artifacts-executor/utils.js'); + const meta={root,reactNativePath:join(process.cwd(),'node_modules/react-native'),dependencies:Object.fromEntries(pins.libraries.map(value=>[JSON.parse(readFileSync(value.packagePath+'/package.json')).name,{root:join(process.cwd(),value.packagePath),platforms:{ios:{}}}]))};file('ios/build/generated/autolinking/autolinking.json',meta); + const log=console.log,warn=console.warn;let selected,config; + try{console.log=()=>{};console.warn=()=>{};config=utils.readReactNativeConfig(root,join(root,'ios'));selected=utils.findCodegenEnabledLibraries({name:'notice-fixture'},root,join(root,'ios'),config,{}).filter(value=>!utils.findDisabledLibrariesByPlatform(config,'ios').includes(value.name));}finally{console.log=log;console.warn=warn;} + assert.deepEqual(selected.map(value=>{const pkg=JSON.parse(readFileSync(join(value.libraryPath,'package.json')));return 'npm:'+pkg.name+'@'+pkg.version;}).sort(),pins.libraries.filter(value=>value.codegen).map(value=>value.component).sort()); + assert.deepEqual(pins.coreSourceTrees.map(value=>value.source),['node_modules/react-native/src','node_modules/react-native/React/FBReactNativeSpec']);assert.ok(JSON.parse(readFileSync('node_modules/react-native/package.json')).codegenConfig.libraries.some(value=>value.name==='FBReactNativeSpec')); +})); diff --git a/scripts/notices-runtime-root.test.groovy b/scripts/notices-runtime-root.test.groovy index 79281cbb..bea15117 100644 --- a/scripts/notices-runtime-root.test.groovy +++ b/scripts/notices-runtime-root.test.groovy @@ -7,11 +7,15 @@ import org.codehaus.groovy.control.customizers.ImportCustomizer import java.nio.file.Files import java.security.MessageDigest import groovy.json.JsonSlurper +import org.gradle.api.artifacts.component.* +import org.gradle.internal.component.resolution.failure.exception.ArtifactSelectionException +import org.gradle.internal.component.resolution.failure.interfaces.ArtifactSelectionFailure class PrivateNoticeFailure extends RuntimeException { String getMessage() { throw new IllegalStateException('PRIVATE_GETTER') } } class PrivateCauseFailure extends RuntimeException { int reads; Throwable getCause() { reads++; throw new IllegalStateException('PRIVATE_CAUSE_GETTER') } } class LinkedNoticeFailure extends RuntimeException { int reads; Throwable next; Throwable getCause() { reads++; return next } } class PrivateResolveFailure extends org.gradle.api.artifacts.ResolveException { PrivateResolveFailure(List causes) { super('PRIVATE_RESOLVE_MESSAGE', causes) } } +class UnknownSelectionFailure extends RuntimeException { int reads; Object getFailure() { reads++; throw new IllegalStateException('PRIVATE_UNKNOWN_GETTER') } } assert args.length == 1 File source = new File(args[0]) @@ -31,7 +35,9 @@ try { registered.add(name) if (fault.runTask) { def task = new Expando(); task.doLast = { action -> action.call() }; body.rehydrate(task, body.owner, body.thisObject).call() } }]] - def target = [projectDir: actual, findProject: { path -> assert path == ':app'; lookups++; if (fault.phase == 'root-binding') throw fault.failure; hasApp ? app : null }] + def target + target = [projectDir: actual, findProject: { path -> lookups++; if (path != ':app') return path == ':public-library' ? [path: path, rootProject: fault.foreignProject ? new Object() : target] : null; if (fault.phase == 'root-binding') throw fault.failure; hasApp ? app : null }] + app.rootProject = target; app.path = ':app' def properties = requested == null ? [:] : [noticeMainProjectRoot: requested] // Included builds need not repeat the main invocation's CLI properties. def parent = actual == included ? [startParameter: [projectProperties: properties]] : null @@ -51,7 +57,7 @@ try { } File build = new File(main.parentFile, 'build'), report = new File(build, 'notices/android/failure.json') def clear = { if (build.isDirectory()) build.deleteDir(); else build.delete() } - def entry = { Throwable item, String kind -> [errorClass: kind, classSHA256: MessageDigest.getInstance('SHA-256').digest(item.getClass().getName().getBytes('UTF-8')).encodeHex().toString()] } + def entry = { Object item, String kind -> [errorClass: kind, classSHA256: MessageDigest.getInstance('SHA-256').digest(item.getClass().getName().getBytes('UTF-8')).encodeHex().toString()] } for (phase in ['root-binding', 'task-registration', 'runtime-resolution', 'artifact-collection']) { clear(); def original = phase == 'runtime-resolution' ? new IllegalStateException('PRIVATE_MESSAGE') : new PrivateNoticeFailure(); Throwable caught try { execute(main, main.path, true, [phase: phase, failure: original, diagnostics: true, runTask: true]) } catch (Throwable failure) { caught = failure } @@ -61,12 +67,14 @@ try { assert value == [schema: 1, status: 'INCOMPLETE', complete: false, sourceCommit: 'a' * 40, platform: 'android', stage: 'setup', setup: [phase: 'gradle-resolution', errorCategory: 'command-failed', collector: [phase: phase, errorClass: kind, causeChain: [entry(original, kind)], causeChainEnd: 'end']]] assert !text.contains('PRIVATE') && !text.contains(fixture.path) && !text.contains('stack') } - def inspect = { Throwable original, List expected, String end -> + def inspect = { Throwable original, List expected, String end, Map selection = null, Map options = [:] -> clear(); Throwable caught - try { execute(main, main.path, true, [phase: 'runtime-resolution', failure: original, diagnostics: true, runTask: true]) } catch (Throwable failure) { caught = failure } + try { execute(main, main.path, true, [phase: 'runtime-resolution', failure: original, diagnostics: true, runTask: true] + options) } catch (Throwable failure) { caught = failure } assert caught.is(original) def text = report.text, collector = new JsonSlurper().parseText(text).setup.collector - assert collector == [phase: 'runtime-resolution', errorClass: expected[0].errorClass, causeChain: expected, causeChainEnd: end] + def wanted = [phase: 'runtime-resolution', errorClass: expected[0].errorClass, causeChain: expected, causeChainEnd: end] + if (selection != null) wanted.selection = selection + assert collector == wanted assert !text.contains('PRIVATE') && !text.contains(fixture.path) && !text.contains(original.getClass().getName()) && !text.contains('stack') } def leaf = new IOException('PRIVATE_URL'), nested = new IllegalStateException('PRIVATE_PATH', leaf), sibling = new PrivateCauseFailure() @@ -79,6 +87,31 @@ try { def unknown = new PrivateNoticeFailure(); inspect(unknown, [entry(unknown, 'Throwable')], 'end') def deep = (1..10).collect { new LinkedNoticeFailure() }; (0..8).each { deep[it].next = deep[it + 1] } inspect(deep[0], deep.take(8).collect { entry(it, 'Throwable') }, 'limit'); assert deep.take(8).every { it.reads == 1 } && deep.drop(8).every { it.reads == 0 } + int selectionChecks = 0 + def selectionCase = { Object component, String kind = null, String identity = null, Map options = [:] -> + def detail = [getTargetComponent: { component instanceof Closure ? component.call() : component }, getTargetVariant: { throw new PrivateNoticeFailure() }, getRequestedAttributes: { throw new PrivateNoticeFailure() }, describeRequestTarget: { throw new PrivateNoticeFailure() }, getProblemId: { throw new PrivateNoticeFailure() }] as ArtifactSelectionFailure + def leafFailure = new ArtifactSelectionException('PRIVATE_MESSAGE', detail, []), original = new PrivateResolveFailure([leafFailure]) + def selection = kind == null ? null : [failureClassSHA256: entry(detail, 'Throwable').classSHA256, componentKind: kind, componentSHA256: MessageDigest.getInstance('SHA-256').digest(identity.getBytes('UTF-8')).encodeHex().toString()] + inspect(original, [entry(original, 'ResolveException'), entry(leafFailure, 'GradleException')], 'end', selection, options); selectionChecks++ + return original + } + def reads = [group: 0, module: 0, version: 0] + def module = [getGroup: { ++reads.group == 1 ? 'qa' : 'PRIVATE_CHANGED' }, getModule: { reads.module++; 'artifact' }, getVersion: { reads.version++; '1' }, getDisplayName: { throw new PrivateNoticeFailure() }] as ModuleComponentIdentifier + def selectedOriginal = selectionCase(module, 'module', 'maven:qa:artifact@1'); assert reads.values().every { it == 1 } + def project = { String buildPath, String path -> [getBuild: { [getBuildPath: { buildPath }] as BuildIdentifier }, getProjectPath: { path }, getDisplayName: { throw new PrivateNoticeFailure() }] as ProjectComponentIdentifier } + selectionCase(project(':', ':app'), 'main-project', 'gradle-project::::app') + selectionCase(project(':', ':public-library'), 'main-project', 'gradle-project::::public-library') + selectionCase(project(':other', ':app')); selectionCase(project(':', ':missing')); selectionCase(project(':', ':public-library'), null, null, [foreignProject: true]) + selectionCase([getDisplayName: { throw new PrivateNoticeFailure() }] as ComponentIdentifier); selectionCase(null) + selectionCase([getGroup: { throw new PrivateNoticeFailure() }, getModule: { 'artifact' }, getVersion: { '1' }] as ModuleComponentIdentifier) + selectionCase([getGroup: { 'x' * 513 }, getModule: { 'artifact' }, getVersion: { '1' }] as ModuleComponentIdentifier) + selectionCase({ throw new PrivateNoticeFailure() }) + def unknownSelection = new UnknownSelectionFailure(); inspect(unknownSelection, [entry(unknownSelection, 'Throwable')], 'end'); assert unknownSelection.reads == 0; selectionChecks++ + def detail = [getTargetComponent: { module }] as ArtifactSelectionFailure, innerSelection = new ArtifactSelectionException('PRIVATE_INNER', detail, []), outerSelection = new ArtifactSelectionException('PRIVATE_OUTER', detail, [], innerSelection), competing = new PrivateResolveFailure([outerSelection]) + inspect(competing, [entry(competing, 'ResolveException'), entry(outerSelection, 'GradleException'), entry(innerSelection, 'GradleException')], 'end'); selectionChecks++ + clear(); build.text = 'PRIVATE_WRITER_BLOCKER'; Throwable selectedCaught + try { execute(main, main.path, true, [phase: 'runtime-resolution', failure: selectedOriginal, diagnostics: true, runTask: true]) } catch (Throwable failure) { selectedCaught = failure } + assert selectedCaught.is(selectedOriginal) && !report.exists(); build.delete(); selectionChecks++ for (blocked in ['file', 'symlink']) { clear(); if (blocked == 'file') build.text = 'PRIVATE_BLOCKER'; else Files.createSymbolicLink(build.toPath(), new File(fixture, 'outside').tap { mkdirs() }.toPath()) def original = new IllegalArgumentException('PRIVATE_ORIGINAL'); Throwable caught @@ -86,5 +119,5 @@ try { assert caught.is(original) && !report.exists() build.delete() } - println 'Actual init source: 7 root guards +4 finite Throwable phases +5 bounded cause chains +2 failed/outside writers PASS; no Gradle build.' + println "Actual init source: 7 root guards +4 finite Throwable phases +5 bounded cause chains +${selectionChecks} selection cases +2 failed/outside writers PASS; no Gradle build." } finally { fixture.deleteDir() } diff --git a/scripts/notices-runtime.init.gradle b/scripts/notices-runtime.init.gradle index 7181f2c3..bc70d7b6 100644 --- a/scripts/notices-runtime.init.gradle +++ b/scripts/notices-runtime.init.gradle @@ -15,16 +15,40 @@ def recordNoticeFailure = { File mainRoot, String phase, Throwable failure -> def output = new File(workspace, 'build/notices/android/failure.json') if (output.canonicalFile != output.absoluteFile) return def kind = { Throwable item -> item instanceof org.gradle.api.artifacts.ResolveException ? 'ResolveException' : item instanceof GradleException ? 'GradleException' : item instanceof IOException ? 'IOException' : item instanceof IllegalArgumentException ? 'IllegalArgumentException' : item instanceof IllegalStateException ? 'IllegalStateException' : item instanceof SecurityException ? 'SecurityException' : item instanceof Error ? 'Error' : 'Throwable' } - def chain = [], current = failure, end = 'end' + def sha = { String value -> MessageDigest.getInstance('SHA-256').digest(value.getBytes('UTF-8')).encodeHex().toString() } + def chain = [], current = failure, end = 'end', selection = null, selectionLeaves = 0 def seen = Collections.newSetFromMap(new IdentityHashMap()) while (current != null) { if (!seen.add(current)) { end = 'cycle'; break } if (chain.size() == 8) { end = 'limit'; break } - chain.add([errorClass: kind(current), classSHA256: MessageDigest.getInstance('SHA-256').digest(current.getClass().getName().getBytes('UTF-8')).encodeHex().toString()]) + chain.add([errorClass: kind(current), classSHA256: sha(current.getClass().getName())]) + if (org.gradle.util.GradleVersion.current().version == '9.3.1' && current.getClass().getName() == 'org.gradle.internal.component.resolution.failure.exception.ArtifactSelectionException') { + selectionLeaves++ + selection = null + if (selectionLeaves == 1) { + try { + def detail = current.getFailure(), target = detail.getTargetComponent(), identity = null, componentKind = null + def bounded = { value -> value instanceof String && !value.isEmpty() && value.length() <= 512 } + if (target instanceof ModuleComponentIdentifier) { + def group = target.getGroup(), module = target.getModule(), version = target.getVersion() + if (bounded(group) && bounded(module) && bounded(version)) { identity = 'maven:' + group + ':' + module + '@' + version; componentKind = 'module' } + } else if (target instanceof ProjectComponentIdentifier) { + def buildPath = target.getBuild().getBuildPath(), projectPath = target.getProjectPath() + if (buildPath == ':' && bounded(projectPath)) { + def project = gradle.rootProject.findProject(projectPath) + if (project != null && project.rootProject.is(gradle.rootProject) && project.path == projectPath) { identity = 'gradle-project:' + buildPath + ':' + projectPath; componentKind = 'main-project' } + } + } + if (identity != null) selection = [failureClassSHA256: sha(detail.getClass().getName()), componentKind: componentKind, componentSHA256: sha(identity)] + } catch (Throwable ignored) { } + } + } try { current = current.getCause() } catch (Throwable ignored) { end = 'unreadable'; break } } output.parentFile.mkdirs() - output.text = JsonOutput.toJson([schema: 1, status: 'INCOMPLETE', complete: false, sourceCommit: source, platform: 'android', stage: 'setup', setup: [phase: 'gradle-resolution', errorCategory: 'command-failed', collector: [phase: phase, errorClass: kind(failure), causeChain: chain, causeChainEnd: end]]]) + def collector = [phase: phase, errorClass: kind(failure), causeChain: chain, causeChainEnd: end] + if (selection != null) collector.selection = selection + output.text = JsonOutput.toJson([schema: 1, status: 'INCOMPLETE', complete: false, sourceCommit: source, platform: 'android', stage: 'setup', setup: [phase: 'gradle-resolution', errorCategory: 'command-failed', collector: collector]]) } catch (Throwable ignored) { } } From 3be89ec638e1d5f54bc6f56ae862392fac796250 Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 09:24:27 +0800 Subject: [PATCH 23/37] Resolve only external archives while retaining local project inputs --- docs/app-notices.md | 9 ++++-- scripts/notices-runtime-root.test.groovy | 40 ++++++++++++++++++++++-- scripts/notices-runtime.init.gradle | 2 +- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index 8481f81b..876670bd 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -206,8 +206,13 @@ podspec's installed npm package. The link target, owning package, podspec and selected target bytes are fingerprinted. Undeclared links, targets outside that package and nested links escaping the selected framework remain rejected; a declared link does not supply the vendor's licence. Gradle inputs are the -actual release runtime resolution and artifact hashes. A shared bounded scanner -walks Pods and autolinked Gradle native subtrees, fingerprints source files, and +actual release runtime resolution and artifact hashes. External module archives +use a strict module-only Gradle artifact view; +the unfiltered resolution graph still supplies every selected local project's +build/source/CMake inputs. No local AAR is required or silently approved before +it is built. Unknown graph identifiers, missing projects and module-resolution +errors still fail; no leniency, attribute override or variant reselection is used. +A shared bounded scanner walks Pods and autolinked Gradle native subtrees, fingerprints source files, and finds source-only `vendor`/`third_party` children as well as binary vendors. Native roots are restricted to installed packages, Pods and identified local modules; arbitrary project data is not traversed. diff --git a/scripts/notices-runtime-root.test.groovy b/scripts/notices-runtime-root.test.groovy index bea15117..48e84a99 100644 --- a/scripts/notices-runtime-root.test.groovy +++ b/scripts/notices-runtime-root.test.groovy @@ -29,14 +29,23 @@ try { config.addCompilationCustomizers(new ImportCustomizer().addImports('org.gradle.api.GradleException')) def execute = { File actual, Object requested, boolean hasApp, Map fault=[:] -> def registered = []; int lookups = 0 - def configuration = [resolvedConfiguration: [resolvedArtifacts: []], incoming: [resolutionResult: [allComponents: [iterator: { -> throw fault.failure }, size: { -> 1 }] as Collection]]] + def configuration = [incoming: [resolutionResult: [allComponents: fault.containsKey('graph') ? fault.graph : [iterator: { -> throw fault.failure }, size: { -> 1 }] as Collection], artifactView: { definition -> + def view = new Expando(), filter; int filters = 0 + view.componentFilter = { predicate -> filter = predicate; filters++ } + definition.rehydrate(view, definition.owner, definition.thisObject).call() + assert filters == 1 && view.properties.keySet() == ['componentFilter'] as Set + if (fault.moduleFailure) throw fault.moduleFailure + def selected = (fault.artifacts ?: []).findAll { filter.call(it.id.componentIdentifier) } + if (fault.observeView) fault.observeView(selected) + return [artifacts: [artifacts: selected]] + }]] def app = [findProperty: { name -> new File(main.parentFile, 'build/private-input.json').path }, configurations: [getByName: { name -> if (fault.phase == 'runtime-resolution') throw fault.failure; configuration }], tasks: [register: { name, body -> if (fault.phase == 'task-registration') throw fault.failure registered.add(name) if (fault.runTask) { def task = new Expando(); task.doLast = { action -> action.call() }; body.rehydrate(task, body.owner, body.thisObject).call() } }]] def target - target = [projectDir: actual, findProject: { path -> lookups++; if (path != ':app') return path == ':public-library' ? [path: path, rootProject: fault.foreignProject ? new Object() : target] : null; if (fault.phase == 'root-binding') throw fault.failure; hasApp ? app : null }] + target = [projectDir: actual, findProject: { path -> lookups++; if (path != ':app') return fault.projects?.containsKey(path) ? fault.projects[path] : path == ':public-library' ? [path: path, rootProject: fault.foreignProject ? new Object() : target] : null; if (fault.phase == 'root-binding') throw fault.failure; hasApp ? app : null }] app.rootProject = target; app.path = ':app' def properties = requested == null ? [:] : [noticeMainProjectRoot: requested] // Included builds need not repeat the main invocation's CLI properties. @@ -112,6 +121,31 @@ try { clear(); build.text = 'PRIVATE_WRITER_BLOCKER'; Throwable selectedCaught try { execute(main, main.path, true, [phase: 'runtime-resolution', failure: selectedOriginal, diagnostics: true, runTask: true]) } catch (Throwable failure) { selectedCaught = failure } assert selectedCaught.is(selectedOriginal) && !report.exists(); build.delete(); selectionChecks++ + clear() + def moduleId = [getGroup: { 'qa' }, getModule: { 'library' }, getVersion: { '1' }] as ModuleComponentIdentifier + def bomId = [getGroup: { 'qa' }, getModule: { 'platform' }, getVersion: { '1' }] as ModuleComponentIdentifier + def projectId = project(':', ':qa-library'), appId = project(':', ':app') + File moduleFile = new File(fixture, 'synthetic-module.jar'); moduleFile.text = 'SYNTHETIC_MODULE_BYTES' + def moduleArtifact = [id: [componentIdentifier: moduleId], file: moduleFile], unbuiltProjectArtifact = [id: [componentIdentifier: projectId], file: new File(fixture, 'absent-unbuilt-project.aar')] + File projectDir = new File(main.parentFile, 'node_modules/qa-library/android'); projectDir.mkdirs() + File projectBuild = new File(projectDir, 'build.gradle'); projectBuild.text = '// Synthetic public native source' + File projectOutput = new File(projectDir, 'build'), staging = new File(projectDir, 'configured-native-staging') + def buildProvider = new Expando(); buildProvider.get = { [asFile: projectOutput] } + def localProject = [buildFile: projectBuild, layout: [buildDirectory: buildProvider], extensions: [findByName: { ignored -> [externalNativeBuild: [cmake: [path: new File(projectDir, 'CMakeLists.txt'), buildStagingDirectory: staging]]] }], file: { value -> value }] + int viewChecks = 0 + def mixed = [runTask: true, graph: [moduleId, bomId, projectId, appId].collect { [id: it] }, artifacts: [moduleArtifact, unbuiltProjectArtifact], projects: [':qa-library': localProject], observeView: { selected -> assert selected == [moduleArtifact]; viewChecks++ }] + execute(main, main.path, true, mixed) + def runtime = new JsonSlurper().parse(new File(main.parentFile, 'build/private-input.json')) + assert runtime.schema == 1 && runtime.configuration == 'releaseRuntimeClasspath' && viewChecks == 1 + assert runtime.components == [[group: 'qa', name: 'library', version: '1', artifacts: [[file: moduleFile.absolutePath, sha256: MessageDigest.getInstance('SHA-256').digest(moduleFile.bytes).encodeHex().toString()]]], [projectBuildFile: projectBuild.absolutePath, projectBuildDirectory: projectOutput.canonicalPath, projectCmakeStagingDirectory: staging.canonicalPath]] + assert runtime.metadataOnly == [[group: 'qa', name: 'platform', version: '1']] && !unbuiltProjectArtifact.file.exists() + int strictGraphChecks = 0 + for (bad in [[graph: [[id: [getDisplayName: { 'PRIVATE_UNKNOWN' }] as ComponentIdentifier]], message: 'Unknown release component input'], [graph: [[id: project(':', ':missing')]], message: 'Unresolved native project input']]) { + try { execute(main, main.path, true, mixed + [graph: bad.graph]); assert false } catch (GradleException failure) { assert failure.message == bad.message; strictGraphChecks++ } + } + def unresolvedModule = new IllegalStateException('PRIVATE_MODULE_FAILURE'); Throwable moduleCaught + try { execute(main, main.path, true, mixed + [moduleFailure: unresolvedModule]); assert false } catch (Throwable failure) { moduleCaught = failure } + assert moduleCaught.is(unresolvedModule); strictGraphChecks++ for (blocked in ['file', 'symlink']) { clear(); if (blocked == 'file') build.text = 'PRIVATE_BLOCKER'; else Files.createSymbolicLink(build.toPath(), new File(fixture, 'outside').tap { mkdirs() }.toPath()) def original = new IllegalArgumentException('PRIVATE_ORIGINAL'); Throwable caught @@ -119,5 +153,5 @@ try { assert caught.is(original) && !report.exists() build.delete() } - println "Actual init source: 7 root guards +4 finite Throwable phases +5 bounded cause chains +${selectionChecks} selection cases +2 failed/outside writers PASS; no Gradle build." + println "Actual init source: 7 root guards +4 finite Throwable phases +5 bounded cause chains +${selectionChecks} selection cases +2 failed/outside writers +1 mixed graph +${strictGraphChecks} strict graph/module failures PASS; no Gradle build." } finally { fixture.deleteDir() } diff --git a/scripts/notices-runtime.init.gradle b/scripts/notices-runtime.init.gradle index bc70d7b6..1aabe0bc 100644 --- a/scripts/notices-runtime.init.gradle +++ b/scripts/notices-runtime.init.gradle @@ -80,7 +80,7 @@ gradle.projectsEvaluated { requested = app.findProperty('noticeInputsOutput') if (!requested) throw new GradleException('Explicit private notice output required') configuration = app.configurations.getByName('releaseRuntimeClasspath') - artifacts = configuration.resolvedConfiguration.resolvedArtifacts + artifacts = configuration.incoming.artifactView { componentFilter { it instanceof ModuleComponentIdentifier } }.artifacts.artifacts } observed('artifact-collection') { def metadataOnly = [] From 1a61c5ad8bbec200adccff1ee40497b95866526d Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 09:39:11 +0800 Subject: [PATCH 24/37] Bind three selected Pods to their exact package license sources --- scripts/app-notice-provenance.json | 3 ++ scripts/native-notice-inputs.mjs | 26 ++++++++++++++-- scripts/native-notice-inputs.test.mjs | 43 +++++++++++++++++++++++++-- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/scripts/app-notice-provenance.json b/scripts/app-notice-provenance.json index 74fc01d1..19ccc99c 100644 --- a/scripts/app-notice-provenance.json +++ b/scripts/app-notice-provenance.json @@ -50,6 +50,9 @@ }, "packagedNative": {"ios": {}, "android": {}}, "knownLicenseSources": [ + {"component":"cocoapods:ExpoAppleAuthentication@57.0.2","ecosystem":"cocoapods","version":"57.0.2","public":true,"source":"node_modules/expo-apple-authentication/LICENSE","sha256":"fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4","upstream":"https://registry.npmjs.org/expo-apple-authentication/-/expo-apple-authentication-57.0.2.tgz","upstreamIntegrity":"sha512-00KnlyRceRKski+ljZz8q8JhJZ4D3ZScU+Y0RqS6ElJ8GaCWfd7Qh5tMr47yIU/dKDrOnkzkFUM+6vyHS5HI7Q==","ownerNpmID":"npm:expo-apple-authentication@57.0.2","packagePath":"node_modules/expo-apple-authentication","packageJSONSource":"node_modules/expo-apple-authentication/package.json","packageJSONSHA256":"b5fa503ebfa4512d1e325431a1d7e008339cb75b97db35e9bf2f7e41a1fd9a06","podspecSource":"node_modules/expo-apple-authentication/ios/ExpoAppleAuthentication.podspec","podspecSHA256":"541092863ad1008c3e7b6823a6dde1e88739213a271a9cce4d7f7eff568e59aa","sourceKind":"path"}, + {"component":"cocoapods:ExpoFileSystem@57.0.7","ecosystem":"cocoapods","version":"57.0.7","public":true,"source":"node_modules/expo-file-system/LICENSE","sha256":"fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4","upstream":"https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz","upstreamIntegrity":"sha512-1vkZRadOsIputFDBcclqBa1pO3iXOO6iIsdqJipQWw4V5TSnWgmRTuOF7ro8IzrVue+PHbum++KCG3Tawem4cg==","ownerNpmID":"npm:expo-file-system@57.0.7","packagePath":"node_modules/expo-file-system","packageJSONSource":"node_modules/expo-file-system/package.json","packageJSONSHA256":"db1d8ced7ce04e7daf4e55c3fd543bfbe42e32aa5e192bbb8ae4bda98de50069","podspecSource":"node_modules/expo-file-system/ios/ExpoFileSystem.podspec","podspecSHA256":"c651c016d967b778b8ccd4dea4490454a10ef6c27488d0f05ee9896ca45172f7","sourceKind":"podspec"}, + {"component":"cocoapods:ExpoAdapterGoogleSignIn@16.1.5","ecosystem":"cocoapods","version":"16.1.5","public":true,"source":"node_modules/@react-native-google-signin/google-signin/LICENSE","sha256":"3680a16525f40f4115c71d7f229ed134afb6946e31ae2af83aa35ce0bc58d539","upstream":"https://registry.npmjs.org/@react-native-google-signin/google-signin/-/google-signin-16.1.5.tgz","upstreamIntegrity":"sha512-9rmMwACzHK9Cbd0Yp7efg5uWgiYoLwmNAJh4gRCuMcSQH+qplpK7E4d1Q4tbsD8M9jvvVtRX2Z0AP9wwBKmPrQ==","ownerNpmID":"npm:@react-native-google-signin/google-signin@16.1.5","packagePath":"node_modules/@react-native-google-signin/google-signin","packageJSONSource":"node_modules/@react-native-google-signin/google-signin/package.json","packageJSONSHA256":"8007e4f26f1b1a34a1621f713d8d4bb4a7170f2d976299e14721b74068989727","podspecSource":"node_modules/@react-native-google-signin/google-signin/expo/ios/ExpoAdapterGoogleSignIn.podspec","podspecSHA256":"308fdad5a2016a4c2eb9a24affcdeffb0dbf56e914938843c443d1524b1ed494","sourceKind":"path"}, {"component":"npm:react-native@0.86.3","ecosystem":"npm","version":"0.86.3","public":true,"source":"node_modules/react-native/LICENSE","sha256":"da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93","upstream":"https://github.com/react/react-native/blob/v0.86.3/LICENSE"}, {"component":"npm:expo@57.0.24","ecosystem":"npm","version":"57.0.24","public":true,"source":"node_modules/expo/LICENSE","sha256":"fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4","upstream":"https://github.com/expo/expo/blob/sdk-57/LICENSE"}, {"component":"npm:posthog-react-native@4.84.1","ecosystem":"npm","version":"4.84.1","source":"node_modules/posthog-react-native/LICENSE","sha256":"d3fb92ac01f0bef4078f89aac9f20f76da3383ad45becf6204abdf5495f43f8f","note":"Actual LICENSE includes Apache/Mixpanel text despite MIT package metadata; preserve actual files and review vendored sources."}, diff --git a/scripts/native-notice-inputs.mjs b/scripts/native-notice-inputs.mjs index 3c21b7f2..6f487696 100644 --- a/scripts/native-notice-inputs.mjs +++ b/scripts/native-notice-inputs.mjs @@ -53,6 +53,27 @@ export function podLinkedSources(root,name,source,external){ return {linkedSources,frameworkInputList,externalPodspec:capturedSource(root,spec)}; } +// Only these reviewed Pod/package pairs may use their own package's exact text. +// This never searches parents or supplies a vendored framework's attribution. +function podOwnLicense(root,name,version,source,external){ + const pins=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))).knownLicenseSources??[]; + const pin=pins.find(value=>value.component===`cocoapods:${name}@${version}`&&value.ownerNpmID); + if(!pin)return; + assert.equal(pin.ecosystem,'cocoapods');assert.equal(pin.version,version);assert.ok(['path','podspec'].includes(pin.sourceKind)); + assert.deepEqual(Object.keys(external??{}),[':'+pin.sourceKind],'Require the exact selected Pod source form.'); + const project=realpathSync(root),modules=realpathSync(join(root,'node_modules')),packagePath=safeRelative(pin.packagePath),owner=realpathSync(join(root,packagePath)); + assert.equal('node_modules/'+safeRelative(relative(modules,owner)),packagePath,'Require the exact installed package root.'); + assert.equal(pin.packageJSONSource,packagePath+'/package.json'); + const checked=(logical,expected)=>{safeRelative(logical);const path=realpathSync(join(root,logical));assert.ok(path.startsWith(owner+sep),'Bound licence inputs must stay in their owning package.');const bytes=readFileSync(path);assert.equal(digest(bytes),expected,'Bound licence input bytes changed.');return {path,bytes};}; + const packageJSON=checked(pin.packageJSONSource,pin.packageJSONSHA256),pkg=JSON.parse(noticeText(packageJSON.bytes)),lock=JSON.parse(readFileSync(join(root,'package-lock.json'))).packages?.[packagePath]; + assert.equal(`npm:${pkg.name}@${pkg.version}`,pin.ownerNpmID);assert.equal(lock?.version,pkg.version);assert.equal(lock.resolved,pin.upstream);assert.equal(lock.integrity,pin.upstreamIntegrity); + const spec=checked(pin.podspecSource,pin.podspecSHA256),selected=realpathSync(source),selector=realpathSync(resolve(root,'ios',external[':'+pin.sourceKind])); + if(pin.sourceKind==='path'){assert.equal(selected,dirname(spec.path));assert.equal(selector,selected);} + else{const sandbox=join(project,'ios/Pods',name);assert.equal(selector,spec.path);assert.equal(selected,sandbox);assert.equal(realpathSync(sandbox),sandbox);} + const license=checked(pin.source,pin.sha256),body=noticeText(license.bytes); + return {notice:{source:pin.source,sha256:pin.sha256,body},binding:{ownerNpmID:pin.ownerNpmID,packagePath,sourceKind:pin.sourceKind,packageJSON:{source:pin.packageJSONSource,sha256:pin.packageJSONSHA256},podspec:{source:pin.podspecSource,sha256:pin.podspecSHA256},license:{source:pin.source,sha256:pin.sha256},upstream:pin.upstream,upstreamIntegrity:pin.upstreamIntegrity}}; +} + export function podNoticeInputs(root){ const lockPath=join(root,'ios/Podfile.lock'),support=join(root,'ios/Pods/Target Support Files'); if(!existsSync(lockPath)||!existsSync(support))return {components:[],unresolved:[{component:'native:ios',reason:'missing-resolved-pod-inputs'}],nativeFiles:[]}; @@ -79,8 +100,9 @@ export function podNoticeInputs(root){ const source=external?.[':path']?resolve(root,'ios',external[':path']):join(root,'ios/Pods',name); if(!existsSync(source))unresolved.push({component:`cocoapods:${name}@${version}`,reason:'missing-resolved-pod-source'}); else scan=noticeInputStep('pod-source-scan','cocoapods',()=>{ - const {linkedSources,...metadata}=podLinkedSources(root,name,source,external),value=nativeSourceNotices(root,source,`cocoapods:${name}`,{linkedSources}); - Object.assign(value.nativeInputs[`cocoapods:${name}`],metadata);return value; + const own=podOwnLicense(root,name,version,source,external),{linkedSources,...metadata}=podLinkedSources(root,name,source,external),value=nativeSourceNotices(root,source,`cocoapods:${name}`,{linkedSources}); + if(own)value.notices.push(own.notice); + Object.assign(value.nativeInputs[`cocoapods:${name}`],metadata,...(own?[{ownLicense:own.binding}]:[]));return value; },`cocoapods:${name}@${version}`); components.push(noticeInputStep('pod-notices','cocoapods',()=>nativeNoticeInput({id:`cocoapods:${name}@${version}`,name,version,ecosystem:'cocoapods',...scan,notices:[...notices,...scan.notices].sort((a,b)=>a.source.localeCompare(b.source))}),`cocoapods:${name}@${version}`)); } diff --git a/scripts/native-notice-inputs.test.mjs b/scripts/native-notice-inputs.test.mjs index 9131f466..bdcc7c5f 100644 --- a/scripts/native-notice-inputs.test.mjs +++ b/scripts/native-notice-inputs.test.mjs @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import {execFileSync} from 'node:child_process'; -import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,rmSync,symlinkSync,cpSync,readdirSync} from 'node:fs'; +import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,rmSync,symlinkSync,cpSync,readdirSync,renameSync} from 'node:fs'; import {tmpdir} from 'node:os'; -import {join} from 'node:path'; +import {join,dirname} from 'node:path'; import {createRequire} from 'node:module'; import test from 'node:test'; import {podNoticeInputs,gradleNoticeInputs,unionNoticeInputs,inspectNoticeArchive,noticeInputStep,noticeFailureContext,podLinkedSources} from './native-notice-inputs.mjs'; -import {assertPackagedNativeCoverage,assertSourceNoticePayload,digest,npmNoticeInputs,resolveNoticeCoverage,nativeSourceNotices} from './app-notices.mjs'; +import {assertPackagedNativeCoverage,assertSourceNoticePayload,digest,npmNoticeInputs,resolveNoticeCoverage,nativeSourceNotices,unreviewedOSSNoticeInputs} from './app-notices.mjs'; const require=createRequire(import.meta.url),plist=require('@expo/plist'); const mit=readFileSync(new URL('../node_modules/react-native/LICENSE',import.meta.url),'utf8'); const approval=(item,platform='ios')=>({schema:1,components:[{platform,component:item.id,reviewedVendors:item.vendors,nativeInputs:item.nativeInputs??{},notices:item.notices.map(({source,sha256})=>({source,sha256})),upstream:'https://example.invalid/pinned/native',sectionSHA256:digest([...item.notices].sort((a,b)=>a.source.localeCompare(b.source)).map(value=>value.body).join('\n\n'))}]}); @@ -15,6 +15,43 @@ function fixture(run){ const file=(path,body)=>{mkdirSync(join(root,path,'..'),{recursive:true});writeFileSync(join(root,path),typeof body==='string'||Buffer.isBuffer(body)?body:JSON.stringify(body));return join(root,path);}; try{return run({root,file});}finally{rmSync(root,{recursive:true,force:true});} } +function ownLicenseFixture(run){return fixture(({root,file})=>{ + const provenance=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),pins=provenance.knownLicenseSources.filter(value=>value.ownerNpmID),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),lock={PODS:[], 'EXTERNAL SOURCES':{}}; + assert.equal(pins.length,3);file('package-lock.json',{packages:Object.fromEntries(pins.map(pin=>[pin.packagePath,installed.packages[pin.packagePath]]))});mkdirSync(join(root,'ios/Pods/Target Support Files'),{recursive:true}); + for(const pin of pins){const name=pin.component.slice('cocoapods:'.length,-pin.version.length-1);lock.PODS.push(name+' ('+pin.version+')');lock['EXTERNAL SOURCES'][name]={[':'+pin.sourceKind]:'../'+(pin.sourceKind==='path'?dirname(pin.podspecSource):pin.podspecSource)};for(const path of [pin.packageJSONSource,pin.podspecSource,pin.source])file(path,readFileSync(new URL('../'+path,import.meta.url)));file('ios/Pods/'+name+'/source.m','Synthetic alternate Pod root for source-form negatives');if(pin.sourceKind==='podspec')for(const arch of ['ios-arm64','ios-arm64_x86_64-simulator'])file('ios/Pods/'+name+'/'+name+'.xcframework/'+arch+'/'+name+'.framework/Info.plist','Synthetic framework source inventory');} + file('ios/Podfile.lock',lock);return run({root,file,pins,lock,provenance,capture:()=>podNoticeInputs(root).components}); +});} + +test('three explicitly bound Pods retain exact own-package text without approving their vendor coverage',()=>ownLicenseFixture(({root,pins,provenance,capture})=>{ + const items=capture();assert.equal(items.length,3);assert.equal(provenance.components.length,0);assert.equal(resolveNoticeCoverage(items,provenance).status,'INCOMPLETE'); + for(const pin of pins){const item=items.find(value=>value.id===pin.component),bytes=readFileSync(new URL('../'+pin.source,import.meta.url));assert.equal(item.notices.length,1);assert.equal(item.notices[0].source,pin.source);assert.equal(item.notices[0].sha256,pin.sha256);assert.ok(Buffer.from(item.notices[0].body).equals(bytes));assert.equal(item.nativeInputs['cocoapods:'+item.name].ownLicense.ownerNpmID,pin.ownerNpmID);assert.ok(!JSON.stringify(item).includes(root));} + const filesystem=items.find(value=>value.name==='ExpoFileSystem');assert.equal(filesystem.vendors.length,3);assert.equal(resolveNoticeCoverage([filesystem],approval(filesystem)).unresolved[0].reason,'missing-vendored-notice'); + const publicInput=unreviewedOSSNoticeInputs(items,provenance);assert.equal(publicInput.components.length,3);assert.equal(publicInput.complete,false);for(const value of publicInput.components)assert.equal(value.notices[0].body,items.find(item=>item.id===value.component).notices[0].body); +})); + +for(const name of ['ExpoAppleAuthentication','ExpoFileSystem','ExpoAdapterGoogleSignIn'])test(`${name} own-license binding rejects changed bytes, owner and lock provenance`,()=>ownLicenseFixture(({root,file,pins,capture})=>{ + const pin=pins.find(value=>value.component.startsWith('cocoapods:'+name+'@')); + const mutate=(path,bytes)=>{const before=readFileSync(join(root,path));file(path,bytes);assert.throws(capture);file(path,before);}; + for(const path of [pin.packageJSONSource,pin.podspecSource,pin.source])mutate(path,Buffer.concat([readFileSync(join(root,path)),Buffer.from('\nPRIVATE_CHANGED_BYTES')])); + mutate(pin.source,Buffer.from([0xc3,0x28]));const pkg=JSON.parse(readFileSync(join(root,pin.packageJSONSource))); + for(const changed of [{...pkg,name:'private-other-owner'},{...pkg,version:'999.0.0'}])mutate(pin.packageJSONSource,JSON.stringify(changed)); + const lock=JSON.parse(readFileSync(join(root,'package-lock.json')));for(const changed of [{version:'999.0.0'},{resolved:'https://example.invalid/PRIVATE'},{integrity:'PRIVATE_INTEGRITY'}])mutate('package-lock.json',JSON.stringify({...lock,packages:{...lock.packages,[pin.packagePath]:{...lock.packages[pin.packagePath],...changed}}})); + const outside=file('outside/LICENSE',readFileSync(join(root,pin.source)));rmSync(join(root,pin.source));symlinkSync(outside,join(root,pin.source));assert.throws(capture);rmSync(join(root,pin.source)); +})); + +test('own-license bindings reject source-form switches, extra selectors and wrong canonical Pod roots',()=>ownLicenseFixture(({root,file,pins,lock,capture})=>{ + for(const pin of pins){const name=pin.component.slice('cocoapods:'.length,-pin.version.length-1),original=lock['EXTERNAL SOURCES'][name]; + for(const changed of [{[pin.sourceKind==='path'?':podspec':':path']:'../'+(pin.sourceKind==='path'?pin.podspecSource:dirname(pin.podspecSource))},{...original,privateSelector:'PRIVATE_PATH'}]){file('ios/Podfile.lock',{...lock,'EXTERNAL SOURCES':{...lock['EXTERNAL SOURCES'],[name]:changed}});let failure;try{capture();}catch(error){failure=error;}assert.ok(failure);const safe=JSON.stringify(noticeFailureContext(failure));assert.ok(!safe.includes('PRIVATE')&&!safe.includes(root));}file('ios/Podfile.lock',lock); + if(pin.sourceKind==='path'){const wrong=pin.packagePath+'/wrong-pod-root';file(wrong+'/source.m','Synthetic adjacent source');file('ios/Podfile.lock',{...lock,'EXTERNAL SOURCES':{...lock['EXTERNAL SOURCES'],[name]:{':path':'../'+wrong}}});assert.throws(capture);file('ios/Podfile.lock',lock);} + else{const selected=join(root,'ios/Pods',name),outside=join(root,'outside-pod');renameSync(selected,outside);symlinkSync(outside,selected);assert.throws(capture);rmSync(selected);renameSync(outside,selected);} + } +})); + +test('unknown sibling Pods and changed Pod versions never inherit an explicit package binding',()=>ownLicenseFixture(({file,pins,lock,capture})=>{ + const pin=pins[0],sibling='UnboundSibling';file('ios/Podfile.lock',{...lock,PODS:[...lock.PODS,sibling+' ('+pin.version+')'],'EXTERNAL SOURCES':{...lock['EXTERNAL SOURCES'],[sibling]:{':path':'../'+dirname(pin.podspecSource)}}});assert.deepEqual(capture().find(value=>value.name===sibling).notices,[]); + for(const pin of pins){const name=pin.component.slice('cocoapods:'.length,-pin.version.length-1);file('ios/Podfile.lock',{...lock,PODS:lock.PODS.map(value=>value===name+' ('+pin.version+')'?name+' (999.0.0)':value)});assert.deepEqual(capture().find(value=>value.name===name).notices,[]);}file('ios/Podfile.lock',lock); +})); + test('resolved CocoaPods subspecs and actual generated plist text determine coverage; missing native inputs stay incomplete',()=>fixture(({root,file})=>{ assert.equal(podNoticeInputs(root).unresolved.length,1); file('ios/Podfile.lock','PODS:\n - QA/Core (1.2.3)\n - QA/UI (1.2.3)\n - Missing (4.5.6)\n'); From 86dd277f9d4cc23add39be85fa12edc5bccef0e0 Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 09:57:24 +0800 Subject: [PATCH 25/37] Identify failed Gradle archive scans with verified hashes --- docs/app-notices.md | 6 ++++++ scripts/native-notice-inputs.mjs | 11 ++++++----- scripts/native-notice-inputs.test.mjs | 18 ++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index 876670bd..bd15c0c9 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -67,6 +67,12 @@ source path or filename is included. It never includes raw errors, messages, causes, paths, notice bodies or environment values. The most specific failing Pod operation survives outer graph/union context. No bound is raised on failure. +Gradle archive inspection failures use the fixed `gradle-archive-scan`/`maven` +context with the existing canonical component ID's SHA and the archive SHA +already verified against its bytes. This wraps only the original Python call; +its invocation, bounds, strict exit and exception remain unchanged. Child output +and paths are not copied into this context. + The Gradle init script requires an explicit absolute `noticeMainProjectRoot` and compares canonical project directories before registering its task. Other included builds are skipped; a missing `:app` on the selected root still fails. diff --git a/scripts/native-notice-inputs.mjs b/scripts/native-notice-inputs.mjs index 6f487696..a9152ba1 100644 --- a/scripts/native-notice-inputs.mjs +++ b/scripts/native-notice-inputs.mjs @@ -9,20 +9,21 @@ const require=createRequire(import.meta.url),archiveReader=new URL('./notice-arc // Only finite diagnostics escape the CLI catch. Never serialize an Error, // message, cause, path or unvalidated dependency name into review artifacts. -const operations=new Set(['review-inputs','source-graph','native-inputs','npm-notices','notice-union','reviewed-notices','coverage','pod-lock','pod-lock-entries','pod-acknowledgements','pod-source-scan','pod-notices','source-root','source-provider','source-path','source-boundary','source-bytes','source-npm']); -const components=new Set(['review','metro','ios','android','npm','cocoapods']); +const operations=new Set(['review-inputs','source-graph','native-inputs','npm-notices','notice-union','reviewed-notices','coverage','pod-lock','pod-lock-entries','pod-acknowledgements','pod-source-scan','pod-notices','source-root','source-provider','source-path','source-boundary','source-bytes','source-npm','gradle-archive-scan']); +const components=new Set(['review','metro','ios','android','npm','cocoapods','maven']); const contexts=new WeakMap(); const sourceErrorCodes=new Set(['ERR_INVALID_ARG_TYPE','ERR_INVALID_ARG_VALUE','ERR_INVALID_URL','ERR_ASSERTION','ENOENT','ENOTDIR','EISDIR','ELOOP','EACCES','EPERM']); const assertionCategories=new Map([['Notice must be readable UTF-8.','invalid-utf8'],['Native source link escapes its component.','source-boundary'],['Native source inventory exceeds its bound.','input-bound'],['Too many native source entries.','input-bound'],['Empty or oversized notice.','input-bound'],['Notice inputs exceed the review bound.','input-bound']]); -export function noticeInputStep(operation,component,run,id,source){ +export function noticeInputStep(operation,component,run,id,source,archiveSHA256){ assert.ok(operations.has(operation)&&components.has(component)); + const archive=operation==='gradle-archive-scan';assert.ok(archive?component==='maven'&&typeof id==='string'&&id.startsWith('maven:')&&source===undefined&&typeof archiveSHA256==='string'&&/^[a-f0-9]{64}$/.test(archiveSHA256):component!=='maven'&&archiveSHA256===undefined); try{return run();}catch(thrown){ const error=thrown instanceof Error?thrown:new Error('Notice input failed.'); if(!contexts.has(error)){ let fields={};try{fields={code:error.code,status:error.status,message:error.message};}catch{} const {code,status,message}=fields; const category=code==='ENOENT'?'missing-input':['EACCES','EPERM'].includes(code)?'unreadable-input':error instanceof SyntaxError?'invalid-format':code==='ERR_ASSERTION'?(assertionCategories.get(message)??'invalid-input'):Number.isInteger(status)?'subprocess-failed':'unsupported-input'; - contexts.set(error,{operation,component,errorCategory:category,...(id?{componentSHA256:digest(id)}:{}),...(operation.startsWith('source-')?{errorClass:error instanceof TypeError?'TypeError':error instanceof SyntaxError?'SyntaxError':error instanceof ReferenceError?'ReferenceError':error instanceof RangeError?'RangeError':code==='ERR_ASSERTION'?'AssertionError':'Error',errorCode:sourceErrorCodes.has(code)?code:'other',...(typeof source==='string'?{sourceSHA256:digest(source)}:{})}:{})}); + contexts.set(error,{operation,component,errorCategory:category,...(id?{componentSHA256:digest(id)}:{}),...(archive?{archiveSHA256}:{}),...(operation.startsWith('source-')?{errorClass:error instanceof TypeError?'TypeError':error instanceof SyntaxError?'SyntaxError':error instanceof ReferenceError?'ReferenceError':error instanceof RangeError?'RangeError':code==='ERR_ASSERTION'?'AssertionError':'Error',errorCode:sourceErrorCodes.has(code)?code:'other',...(typeof source==='string'?{sourceSHA256:digest(source)}:{})}:{})}); } throw error; } @@ -124,7 +125,7 @@ export function gradleNoticeInputs(root,inputFile){ assert.ok(value.group&&value.name&&value.version&&value.artifacts.length);const notices=[],vendors=[]; for(const artifact of value.artifacts){ const bytes=readFileSync(artifact.file);assert.equal(digest(bytes),artifact.sha256,'Resolved artifact changed.'); - const inspected=inspectNoticeArchive(artifact.file),prefix='maven:'+value.group+':'+value.name+':'+value.version+'/'+basename(artifact.file)+'!'; + const inspected=noticeInputStep('gradle-archive-scan','maven',()=>inspectNoticeArchive(artifact.file),`maven:${value.group}:${value.name}@${value.version}`,undefined,artifact.sha256),prefix='maven:'+value.group+':'+value.name+':'+value.version+'/'+basename(artifact.file)+'!'; notices.push(...inspected.notices.map(item=>({...item,source:prefix+item.source}))); nativeFiles.push(...inspected.nativeFiles.map(item=>({...item,component:`maven:${value.group}:${value.name}@${value.version}`}))); for(const item of inspected.notices)if(/(^|\/)(vendor|third[-_]?party)(\/|$)/i.test(item.source))vendors.push(item.source); diff --git a/scripts/native-notice-inputs.test.mjs b/scripts/native-notice-inputs.test.mjs index bdcc7c5f..223f524b 100644 --- a/scripts/native-notice-inputs.test.mjs +++ b/scripts/native-notice-inputs.test.mjs @@ -214,6 +214,24 @@ test('source diagnostics keep ReferenceError finite and snapshot changing or thr } }); +test('archive diagnostics preserve the original error and expose only fixed enums and verified hashes',()=>{ + const hash=digest('SYNTHETIC_ARCHIVE'),id='maven:PRIVATE_GROUP:PRIVATE_MODULE@PRIVATE_VERSION',original=new Error('PRIVATE_MESSAGE'),reads={stdout:0,stderr:0,stack:0,status:0}; + for(const key of ['stdout','stderr','stack'])Object.defineProperty(original,key,{get(){reads[key]++;throw new Error('PRIVATE_CHILD_OUTPUT');}}); + Object.defineProperty(original,'status',{get(){return ++reads.status===1?7:'PRIVATE_CHANGED_STATUS';}}); + let caught;try{noticeInputStep('native-inputs','android',()=>noticeInputStep('gradle-archive-scan','maven',()=>{throw original;},id,undefined,hash));}catch(error){caught=error;} + assert.equal(caught,original);assert.deepEqual(reads,{stdout:0,stderr:0,stack:0,status:1});assert.deepEqual(noticeFailureContext(caught),{operation:'gradle-archive-scan',component:'maven',errorCategory:'subprocess-failed',componentSHA256:digest(id),archiveSHA256:hash});assert.ok(!JSON.stringify(noticeFailureContext(caught)).includes('PRIVATE')); + for(const hashValue of [undefined,'PRIVATE_HASH',hash.toUpperCase(),{toString(){throw new Error('PRIVATE_COERCION');}}]){let invoked=false;assert.throws(()=>noticeInputStep('gradle-archive-scan','maven',()=>{invoked=true;},id,undefined,hashValue));assert.equal(invoked,false);} + for(const [operation,component,source] of [['pod-source-scan','cocoapods',undefined],['pod-source-scan','maven',undefined],['gradle-archive-scan','android',undefined],['gradle-archive-scan','maven','PRIVATE_PATH']])assert.throws(()=>noticeInputStep(operation,component,()=>{},id,source,hash)); +}); + +test('actual Gradle archive inspection reports its verified component/archive hashes and keeps success and drift strict',()=>fixture(({root,file})=>{ + const path=file('PRIVATE_ARCHIVE.aar','PRIVATE_NOT_A_ZIP'),hash=digest(readFileSync(path)),input={schema:1,configuration:'releaseRuntimeClasspath',components:[{group:'qa',name:'archive',version:'1',artifacts:[{file:path,sha256:hash}]}]},metadata=file('private-runtime.json',input); + let failure;try{noticeInputStep('native-inputs','android',()=>gradleNoticeInputs(root,metadata));}catch(error){failure=error;} + assert.equal(failure.status,1,'The original Python nonzero exit is preserved.');assert.deepEqual(noticeFailureContext(failure),{operation:'gradle-archive-scan',component:'maven',errorCategory:'subprocess-failed',componentSHA256:digest('maven:qa:archive@1'),archiveSHA256:hash});for(const secret of [root,'PRIVATE','Traceback','stderr','message'])assert.ok(!JSON.stringify(noticeFailureContext(failure)).includes(secret)); + input.components[0].artifacts[0].sha256=digest('DIFFERENT_BYTES');file('private-runtime.json',input);let drift;try{noticeInputStep('native-inputs','android',()=>gradleNoticeInputs(root,metadata));}catch(error){drift=error;}assert.ok(drift.message.startsWith('Resolved artifact changed.'));assert.equal(noticeFailureContext(drift).operation,'native-inputs');assert.equal(noticeFailureContext(drift).archiveSHA256,undefined,'Unverified bytes cannot gain archive context.'); + execFileSync('python3',['-c',"import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:z.writestr('META-INF/LICENSE','Actual synthetic notice\\n')",path]);const bytes=readFileSync(path);input.components[0].artifacts[0].sha256=digest(bytes);file('private-runtime.json',input);const result=gradleNoticeInputs(root,metadata);assert.equal(result.components[0].id,'maven:qa:archive@1');assert.deepEqual(result.components[0].notices,[{source:'maven:qa:archive:1/PRIVATE_ARCHIVE.aar!META-INF/LICENSE',sha256:digest('Actual synthetic notice\n'),body:'Actual synthetic notice\n'}]);assert.deepEqual(result.nativeFiles,[]); +})); + test('Pods follow only exact selected frameworks into the locked podspec package and still require vendor notices',()=>fixture(({root,file})=>{ const pkg='node_modules/qa-sdk',pod='ios/Pods/QA',list='ios/Pods/Target Support Files/QA/QA-xcframeworks-input-files.xcfilelist'; From b37e6a3c668e418cedf220b3fcf1e1743608cb85 Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 10:28:04 +0800 Subject: [PATCH 26/37] Stream bounded native archive hashes with a separate size cap --- docs/app-notices.md | 11 ++- scripts/native-notice-inputs.test.mjs | 3 + scripts/notice-archive.py | 22 +++++- scripts/notice-archive.test.py | 110 ++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 scripts/notice-archive.test.py diff --git a/docs/app-notices.md b/docs/app-notices.md index bd15c0c9..da5f7d1e 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -70,9 +70,18 @@ Pod operation survives outer graph/union context. No bound is raised on failure. Gradle archive inspection failures use the fixed `gradle-archive-scan`/`maven` context with the existing canonical component ID's SHA and the archive SHA already verified against its bytes. This wraps only the original Python call; -its invocation, bounds, strict exit and exception remain unchanged. Child output +its invocation, strict exit and exception remain unchanged. Child output and paths are not copied into this context. +Native archive entries have a separate 192 MiB cap and SHA-256 is streamed in +1 MiB chunks through ZIP EOF/CRC validation, requiring the declared byte count. +Text (1 MiB each/16 MiB total), payload (16 MiB), bundle and nested JAR (128 MiB) +bounds remain unchanged. The exact public React Android 0.86.3 release AAR +(`59b66e453d8775a54df66a321c0d5002b98716d68c4bd9edb8e64d7110ff8d28`) +has 32 native entries; its largest is 140,693,272 bytes, above the previous +128 MiB shared bound. This establishes the scoped size failure, not complete +notice coverage or a qualified app artifact. + The Gradle init script requires an explicit absolute `noticeMainProjectRoot` and compares canonical project directories before registering its task. Other included builds are skipped; a missing `:app` on the selected root still fails. diff --git a/scripts/native-notice-inputs.test.mjs b/scripts/native-notice-inputs.test.mjs index 223f524b..05e52673 100644 --- a/scripts/native-notice-inputs.test.mjs +++ b/scripts/native-notice-inputs.test.mjs @@ -9,6 +9,9 @@ import {podNoticeInputs,gradleNoticeInputs,unionNoticeInputs,inspectNoticeArchiv import {assertPackagedNativeCoverage,assertSourceNoticePayload,digest,npmNoticeInputs,resolveNoticeCoverage,nativeSourceNotices,unreviewedOSSNoticeInputs} from './app-notices.mjs'; const require=createRequire(import.meta.url),plist=require('@expo/plist'); const mit=readFileSync(new URL('../node_modules/react-native/LICENSE',import.meta.url),'utf8'); +test('archive native hashing streams bounded bytes and preserves CRC, size and nonnative failure gates',()=>{ + execFileSync('python3',[new URL('./notice-archive.test.py',import.meta.url).pathname],{stdio:['ignore','pipe','pipe'],timeout:30000}); +}); const approval=(item,platform='ios')=>({schema:1,components:[{platform,component:item.id,reviewedVendors:item.vendors,nativeInputs:item.nativeInputs??{},notices:item.notices.map(({source,sha256})=>({source,sha256})),upstream:'https://example.invalid/pinned/native',sectionSHA256:digest([...item.notices].sort((a,b)=>a.source.localeCompare(b.source)).map(value=>value.body).join('\n\n'))}]}); function fixture(run){ const root=mkdtempSync(join(tmpdir(),'puptime-native-notice-')); diff --git a/scripts/notice-archive.py b/scripts/notice-archive.py index 45c0ee67..c873e47e 100644 --- a/scripts/notice-archive.py +++ b/scripts/notice-archive.py @@ -12,6 +12,24 @@ MAX_TEXT = 1024 * 1024 MAX_TOTAL_TEXT = 16 * MAX_TEXT MAX_NESTED = 128 * MAX_TEXT +MAX_NATIVE = 192 * MAX_TEXT +NATIVE_CHUNK = MAX_TEXT + + +def native_digest(archive, entry): + if entry.file_size > MAX_NATIVE: + raise ValueError("Oversized native input") + digest = hashlib.sha256() + observed = 0 + with archive.open(entry) as stream: + while chunk := stream.read(NATIVE_CHUNK): + observed += len(chunk) + if observed > entry.file_size or observed > MAX_NATIVE: + raise ValueError("Native input size mismatch") + digest.update(chunk) + if observed != entry.file_size: + raise ValueError("Native input size mismatch") + return digest.hexdigest() def is_macho(archive, entry): @@ -80,9 +98,7 @@ def inspect(source, prefix="", depth=0): raise ValueError("Unreadable notice") result["notices"].append({"source": logical, "body": body, "sha256": hashlib.sha256(raw).hexdigest()}) if name.endswith(".so") or is_macho(archive, entry): - if entry.file_size > MAX_NESTED: - raise ValueError("Oversized native input") - result["nativeFiles"].append({"path": logical, "sha256": hashlib.sha256(archive.read(entry)).hexdigest()}) + result["nativeFiles"].append({"path": logical, "sha256": native_digest(archive, entry)}) if depth == 0 and name.endswith(".jar"): if entry.file_size > MAX_NESTED: raise ValueError("Oversized nested SDK archive") diff --git a/scripts/notice-archive.test.py b/scripts/notice-archive.test.py new file mode 100644 index 00000000..ca7b6e56 --- /dev/null +++ b/scripts/notice-archive.test.py @@ -0,0 +1,110 @@ +"""Bounded native hashing; no SDK setup or native execution.""" +import hashlib +import importlib.util +import io +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +import zipfile + +spec = importlib.util.spec_from_file_location("notice_archive", Path(__file__).with_name("notice-archive.py")) +reader = importlib.util.module_from_spec(spec) +spec.loader.exec_module(reader) + + +class NativeArchiveTests(unittest.TestCase): + def test_native_stream_never_uses_unbounded_reads(self): + class BoundedStream(io.BytesIO): + def read(self, size=-1): + self_test.assertGreater(size, 0) + self_test.assertLessEqual(size, 1024 * 1024) + return super().read(size) + + self_test = self + data = b"native bytes" * 200000 + archive = SimpleNamespace(open=lambda _: BoundedStream(data)) + self.assertEqual(reader.native_digest(archive, SimpleNamespace(file_size=len(data))), hashlib.sha256(data).hexdigest()) + + def test_native_sizes_and_original_read_error_stay_strict(self): + archive = SimpleNamespace(open=lambda _: io.BytesIO(b"1234")) + for size in (3, 5): + with self.assertRaisesRegex(ValueError, "Native input size mismatch"): + reader.native_digest(archive, SimpleNamespace(file_size=size)) + opened = [] + with self.assertRaisesRegex(ValueError, "Oversized native input"): + reader.native_digest(SimpleNamespace(open=lambda _: opened.append(True)), SimpleNamespace(file_size=reader.MAX_NATIVE + 1)) + self.assertEqual(opened, []) + original = OSError("synthetic read failure") + + class FailedStream(io.BytesIO): + def read(self, size=-1): + if self.tell() == 0: + return super().read(1) + raise original + + with self.assertRaises(OSError) as caught: + reader.native_digest(SimpleNamespace(open=lambda _: FailedStream(b"12")), SimpleNamespace(file_size=2)) + self.assertIs(caught.exception, original) + + def test_real_zip_crc_and_truncation_errors_propagate(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "native.aar" + data = b"\x7fELF" + b"native" * 1000 + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("jni/arm64-v8a/libqa.so", data) + self.assertEqual(reader.inspect_archive(path)["nativeFiles"][0]["sha256"], hashlib.sha256(data).hexdigest()) + original = path.read_bytes() + with zipfile.ZipFile(path) as archive: + entry = archive.infolist()[0] + offset = entry.header_offset + 30 + len(entry.filename.encode()) + len(entry.extra) + corrupt = bytearray(original) + corrupt[offset] ^= 1 + path.write_bytes(corrupt) + with self.assertRaises(zipfile.BadZipFile): + reader.inspect_archive(path) + path.write_bytes(original[:-10]) + with self.assertRaises(zipfile.BadZipFile): + reader.inspect_archive(path) + + def test_native_above_old_cap_streams_but_bundle_and_jar_caps_do_not_change(self): + self.assertEqual((reader.MAX_TEXT, reader.MAX_TOTAL_TEXT, reader.MAX_NESTED, reader.MAX_NATIVE), (1024**2, 16 * 1024**2, 128 * 1024**2, 192 * 1024**2)) + chunk = bytes(1024**2) + expected = hashlib.sha256() + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "large.aar" + for name in ("libqa.so", "classes.jar"): + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + with archive.open(name, "w") as stream: + for _ in range(129): + stream.write(chunk) + if name == "libqa.so": + expected.update(chunk) + if name == "libqa.so": + self.assertEqual(reader.inspect_archive(path)["nativeFiles"], [{"path": name, "sha256": expected.hexdigest()}]) + with self.assertRaisesRegex(ValueError, "Empty or oversized application bundle"): + reader.inspect_archive(path, bundle_path=name) + with zipfile.ZipFile(path) as archive: + entry = archive.infolist()[0] + entry.file_size = reader.MAX_NATIVE + 1 + with self.assertRaisesRegex(ValueError, "Oversized native input"): + reader.native_digest(archive, entry) + else: + with self.assertRaisesRegex(ValueError, "Oversized nested SDK archive"): + reader.inspect_archive(path) + + def test_text_and_payload_caps_are_unchanged(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "text.zip" + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("LICENSE", b"a" * (1024**2 + 1)) + with self.assertRaisesRegex(ValueError, "Empty or oversized notice"): + reader.inspect_archive(path) + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("third_party_notices.json", b"a" * (16 * 1024**2 + 1)) + with self.assertRaisesRegex(ValueError, "Oversized notice payload"): + reader.inspect_archive(path, payload_path="third_party_notices.json") + + +if __name__ == "__main__": + unittest.main() From 5464a4f4ce535c7c82de4fa5bfda074e06104b5e Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 10:50:47 +0800 Subject: [PATCH 27/37] Recognize bounded JVM class structure independent of filename --- docs/app-notices.md | 9 +++ scripts/native-notice-inputs.test.mjs | 2 +- scripts/notice-archive.py | 95 ++++++++++++++++++++++++++- scripts/notice-archive.test.py | 94 ++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 2 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index da5f7d1e..ae9e366a 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -82,6 +82,15 @@ has 32 native entries; its largest is 140,693,272 bytes, above the previous 128 MiB shared bound. This establishes the scoped size failure, not complete notice coverage or a qualified app artifact. +`CAFEBABE` also identifies JVM class bytes. After valid universal-native tables +are checked first, the reader recognizes JVM structure independently of the +filename: known version/pool tags and references, member and attribute framing, +and exact EOF/CRC within the existing 128 MiB nested-input bound. It does not +execute or semantically verify bytecode. Malformed or unknown ambiguous inputs +still fail. The pinned [Kotlin producer](https://github.com/Kotlin/kotlinx.coroutines/blob/1.10.2/kotlinx-coroutines-core/jvm/src/debug/internal/AgentPremain.kt#L40) +uses `DebugProbesKt.bin` as class bytes; this is a format distinction, not an +archive-member exclusion or a notice-coverage approval. + The Gradle init script requires an explicit absolute `noticeMainProjectRoot` and compares canonical project directories before registering its task. Other included builds are skipped; a missing `:app` on the selected root still fails. diff --git a/scripts/native-notice-inputs.test.mjs b/scripts/native-notice-inputs.test.mjs index 05e52673..74f5b7c1 100644 --- a/scripts/native-notice-inputs.test.mjs +++ b/scripts/native-notice-inputs.test.mjs @@ -162,7 +162,7 @@ test('Pods use the same source-only vendor boundary rule and reject raw invalid test('archive inventory includes extensionless main/appex Mach-O and unknown executables require coverage',()=>fixture(({root})=>{ const path=join(root,'fixture.ipa'); - execFileSync('python3',['-c',`import zipfile,struct,sys\nfat=struct.pack('>II',0xcafebabe,2)+struct.pack('>IIIII',12,0,48,8,0)+struct.pack('>IIIII',7,0,56,8,0)+b'\\xcf\\xfa\\xed\\xfeabcd'*2\nwith zipfile.ZipFile(sys.argv[1],'w') as z:\n for name in ['Payload/Puptime.app/Puptime','Payload/Puptime.app/PlugIns/Unknown.appex/Unknown','Payload/Puptime.app/Frameworks/QA.framework/QA']:z.writestr(name,b'\\xcf\\xfa\\xed\\xfecontrolled')\n z.writestr('Payload/Puptime.app/fat-native',fat);z.writestr('Payload/Puptime.app/Qa.class',bytes.fromhex('cafebabe000000340001'));z.writestr('Payload/Puptime.app/text','plain text')`,path]); + execFileSync('python3',['-c',`import zipfile,struct,sys\nfat=struct.pack('>II',0xcafebabe,2)+struct.pack('>IIIII',12,0,48,8,0)+struct.pack('>IIIII',7,0,56,8,0)+b'\\xcf\\xfa\\xed\\xfeabcd'*2\nwith zipfile.ZipFile(sys.argv[1],'w') as z:\n for name in ['Payload/Puptime.app/Puptime','Payload/Puptime.app/PlugIns/Unknown.appex/Unknown','Payload/Puptime.app/Frameworks/QA.framework/QA']:z.writestr(name,b'\\xcf\\xfa\\xed\\xfecontrolled')\n z.writestr('Payload/Puptime.app/fat-native',fat);z.writestr('Payload/Puptime.app/Qa.class',bytes.fromhex('cafebabe00000034000501000251410700010100106a6176612f6c616e672f4f626a6563740700030021000200040000000000000000'));z.writestr('Payload/Puptime.app/text','plain text')`,path]); const result=inspectNoticeArchive(path);assert.equal(result.nativeFiles.length,4);assert.ok(!result.nativeFiles.some(value=>value.path.endsWith('.class'))); const component='native:qa@1',framework=result.nativeFiles.find(value=>value.path.includes('.framework/')); const ledger={packagedNative:{ios:{[framework.path]:{components:[component],review:'https://example.invalid/pinned/native'}}}}; diff --git a/scripts/notice-archive.py b/scripts/notice-archive.py index c873e47e..cdd6c1ee 100644 --- a/scripts/notice-archive.py +++ b/scripts/notice-archive.py @@ -32,6 +32,98 @@ def native_digest(archive, entry): return digest.hexdigest() +def jvm_class_structure(archive, entry): + # JVMS 4.1/4.4/4.7 framing, not bytecode verification or execution. + def require(condition): + if not condition: + raise ValueError("Malformed or unsupported universal native input") + + require(10 <= entry.file_size <= MAX_NESTED) + remaining = entry.file_size + with archive.open(entry) as stream: + def take(size): + nonlocal remaining + require(0 <= size <= min(remaining, MAX_TEXT)) + value = stream.read(size) + require(len(value) == size) + remaining -= size + return value + + def integer(size=2): + return int.from_bytes(take(size), "big") + + def skip(size): + require(size <= remaining) + while size: + chunk = min(size, MAX_TEXT) + take(chunk) + size -= chunk + + require(take(4) == b"\xca\xfe\xba\xbe") + minor, major = integer(), integer() + require(45 <= major <= 69 and (major < 56 or minor in (0, 65535))) + count = integer() + require(count > 0) + pool, references, index = [None] * count, [], 1 + while index < count: + tag = integer(1) + pool[index] = tag + require(tag in (1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 15, 16, 17, 18, 19, 20)) + require(tag not in (15, 16, 18) or major >= 51) + require(tag not in (19, 20) or major >= 53) + require(tag != 17 or major >= 55) + if tag == 1: + value = take(integer()) + require(b"\0" not in value and all(byte < 0xf0 for byte in value)) + # Modified UTF-8 permits encoded NUL and surrogate code units. + value.replace(b"\xc0\x80", b"\0").decode("utf-8", errors="surrogatepass") + elif tag in (3, 4, 5, 6): + skip(8 if tag in (5, 6) else 4) + if tag in (5, 6): + index += 1 + require(index < count) + elif tag in (7, 8, 16, 19, 20): + references.append((integer(), (1,))) + elif tag in (9, 10, 11, 12): + references.extend([(integer(), (1,) if tag == 12 else (7,)), (integer(), (1,) if tag == 12 else (12,))]) + elif tag in (17, 18): + integer() # Bootstrap-method table index, not a pool reference. + references.append((integer(), (12,))) + else: + kind = integer(1) + require(1 <= kind <= 9) + allowed = (9,) if kind <= 4 else (11,) if kind == 9 else (10, 11) if kind in (6, 7) and major >= 52 else (10,) + references.append((integer(), allowed)) + index += 1 + + def reference(index, tags): + require(0 < index < count and pool[index] in tags) + + for index, tags in references: + reference(index, tags) + + def attributes(): + for _ in range(integer()): + reference(integer(), (1,)) + skip(integer(4)) + + integer() # access_flags + reference(integer(), (7,)) + superclass = integer() + if superclass: + reference(superclass, (7,)) + for _ in range(integer()): + reference(integer(), (7,)) + for _ in range(2): # fields, then methods + for _ in range(integer()): + integer() + reference(integer(), (1,)) + reference(integer(), (1,)) + attributes() + attributes() + require(remaining == 0 and stream.read(1) == b"") + + def is_macho(archive, entry): # CAFEBABE is shared with JVM class files. Check the bounded universal # architecture table, not magic alone (Apple mach-o/fat.h; JVMS 4.1). @@ -51,7 +143,8 @@ def is_macho(archive, entry): ranges = [struct.unpack_from(endian + ("QQ" if wide else "II"), table, i * width + 8) for i in range(count)] if all(offset >= 8 + count * width and size >= 4 and offset + size <= entry.file_size for offset, size in ranges): return True - if magic == b"\xca\xfe\xba\xbe" and entry.filename.endswith(".class") and len(header) == 8 and struct.unpack(">H", header[6:8])[0] >= 45: + if magic == b"\xca\xfe\xba\xbe": + jvm_class_structure(archive, entry) return False raise ValueError("Malformed or unsupported universal native input") diff --git a/scripts/notice-archive.test.py b/scripts/notice-archive.test.py index ca7b6e56..49377f6a 100644 --- a/scripts/notice-archive.test.py +++ b/scripts/notice-archive.test.py @@ -3,6 +3,7 @@ import importlib.util import io from pathlib import Path +import struct import tempfile from types import SimpleNamespace import unittest @@ -13,7 +14,100 @@ spec.loader.exec_module(reader) +def jvm_fixture(extra=b"", slots=0, major=52): + pool = b"\x01\x00\x02QA\x07\x00\x01\x01\x00\x10java/lang/Object\x07\x00\x03" + return b"\xca\xfe\xba\xbe" + struct.pack(">HHH", 0, major, 5 + slots) + pool + extra + struct.pack(">7H", 0x21, 2, 4, 0, 0, 0, 0) + + class NativeArchiveTests(unittest.TestCase): + def test_positive_jvm_structure_does_not_depend_on_extension(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "classes.jar" + with zipfile.ZipFile(path, "w") as archive: + for name in ("QA.class", "DebugProbesKt.bin", "extensionless"): + archive.writestr(name, jvm_fixture()) + archive.writestr("modified-utf8", jvm_fixture(b"\x01\x00\x05\xc0\x80\xed\xa0\x80", 1)) + self.assertEqual(reader.inspect_archive(path)["nativeFiles"], []) + + def test_malformed_jvm_pool_version_references_lengths_and_eof_fail(self): + valid = jvm_fixture() + invalid = [valid[:8] + b"\x00\x00" + valid[10:], valid[:-1], valid + b"\0", + valid[:10] + b"\x02" + valid[11:], valid[:16] + b"\x00\xff" + valid[18:], + valid[:-12] + b"\x00\x01" + valid[-10:], + valid[:-2] + b"\x00\x01\x00\x01\x00\x00\x00\x03ab", + jvm_fixture(b"\x05" + bytes(8), 1), + jvm_fixture(b"\x05" + bytes(8) + b"\x07\x00\x06", 3), + jvm_fixture(b"\x13\x00\x01", 1), jvm_fixture(major=70), + jvm_fixture(b"\x01\x00\x01\x00", 1), jvm_fixture(b"\x01\x00\x01\xff", 1), + b"\xca\xfe\xba\xbe\x00\x00\x00\x34" + bytes(1100)] + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "invalid.jar" + for data in invalid: + with self.subTest(sha256=hashlib.sha256(data).hexdigest()): + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("ambiguous.bin", data) + with self.assertRaises(ValueError): + reader.inspect_archive(path) + + def test_valid_fat_32_64_swapped_and_jvm_looking_header_are_native_first(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "fat.zip" + with zipfile.ZipFile(path, "w") as archive: + for endian in (">", "<"): + for wide in (False, True): + width = 32 if wide else 20 + table = struct.pack(endian + ("IIQQII" if wide else "IIIII"), 12, 0, 8 + width, 4, 0, *([0] if wide else [])) + archive.writestr(f"{endian}-{wide}.class", struct.pack(endian + "II", 0xcafebabf if wide else 0xcafebabe, 1) + table + bytes(4)) + table = struct.pack(">IIIII", 12, 0, 1048, 4, 0) * 52 + archive.writestr("major52.bin", struct.pack(">II", 0xcafebabe, 52) + table + bytes(4)) + self.assertEqual(len(reader.inspect_archive(path)["nativeFiles"]), 5) + + def test_jvm_crc_and_original_read_errors_propagate(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "crc.jar" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("QA.bin", jvm_fixture()) + with zipfile.ZipFile(path) as archive: + entry = archive.infolist()[0] + offset = entry.header_offset + 30 + len(entry.filename.encode()) + len(entry.extra) + corrupt = bytearray(path.read_bytes()) + corrupt[offset + 13] ^= 1 + path.write_bytes(corrupt) + with self.assertRaises(zipfile.BadZipFile): + reader.inspect_archive(path) + original = OSError("synthetic JVM read failure") + + class FailedStream(io.BytesIO): + def read(self, size=-1): + if self.tell() >= 8: + raise original + return super().read(size) + + with self.assertRaises(OSError) as caught: + reader.jvm_class_structure(SimpleNamespace(open=lambda _: FailedStream(jvm_fixture())), SimpleNamespace(file_size=len(jvm_fixture()))) + self.assertIs(caught.exception, original) + + def test_jvm_attribute_skip_and_declared_file_bound_are_finite(self): + data = jvm_fixture()[:-2] + struct.pack(">HHI", 1, 1, 2 * 1024**2) + bytes(2 * 1024**2) + requests = [] + + class BoundedStream(io.BytesIO): + def read(self, size=-1): + requests.append(size) + if not 0 <= size <= 1024**2: + raise AssertionError("Unbounded JVM read") + return super().read(size) + + archive = SimpleNamespace(open=lambda _: BoundedStream(data)) + reader.jvm_class_structure(archive, SimpleNamespace(file_size=len(data))) + self.assertIn(1024**2, requests) + with self.assertRaises(ValueError): + reader.jvm_class_structure(archive, SimpleNamespace(file_size=len(data) + 1)) + requests.clear() + with self.assertRaises(ValueError): + reader.jvm_class_structure(archive, SimpleNamespace(file_size=128 * 1024**2 + 1)) + self.assertEqual(requests, []) + def test_native_stream_never_uses_unbounded_reads(self): class BoundedStream(io.BytesIO): def read(self, size=-1): From 5d4cfa725db88d583549a088361e11ef558029fc Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 10:54:30 +0800 Subject: [PATCH 28/37] Bind 97 reviewed Pod own-package licence sources explicitly --- docs/app-notices.md | 9 + scripts/app-notice-provenance.json | 1651 ++++++++++++++++++++++++- scripts/native-notice-inputs.test.mjs | 27 +- 3 files changed, 1683 insertions(+), 4 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index da5f7d1e..4db7392d 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -5,6 +5,15 @@ reviewed font/icon notices only. An empty component ledger deliberately prevents sealing. Source tests, an internal simulator build, and finding licence filenames do not establish complete app-library notice coverage or store readiness. +The own-package source records additionally bind 97 selected RN/Expo Pods to +28 exact public NPM owners. Package, podspec and original MIT text hashes match +lock-integrity-verified published tarballs; each reviewed podspec reads its own +package licence. The 91 literal directory/6 exact podspec forms use the existing +strict binding helper. These records preserve own-package attribution only. +ExpoRouter/ExpoUI without own text, Yoga, the six missing-text NPM components, +Metro prelude, and every vendor/generated boundary remain independently +unresolved. The component-approval ledger and offline payload are unchanged. + ## Stable offline resource `assets/notices/third_party_notices.json` is a **committed platform union** of diff --git a/scripts/app-notice-provenance.json b/scripts/app-notice-provenance.json index 19ccc99c..b13a333c 100644 --- a/scripts/app-notice-provenance.json +++ b/scripts/app-notice-provenance.json @@ -56,7 +56,1656 @@ {"component":"npm:react-native@0.86.3","ecosystem":"npm","version":"0.86.3","public":true,"source":"node_modules/react-native/LICENSE","sha256":"da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93","upstream":"https://github.com/react/react-native/blob/v0.86.3/LICENSE"}, {"component":"npm:expo@57.0.24","ecosystem":"npm","version":"57.0.24","public":true,"source":"node_modules/expo/LICENSE","sha256":"fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4","upstream":"https://github.com/expo/expo/blob/sdk-57/LICENSE"}, {"component":"npm:posthog-react-native@4.84.1","ecosystem":"npm","version":"4.84.1","source":"node_modules/posthog-react-native/LICENSE","sha256":"d3fb92ac01f0bef4078f89aac9f20f76da3383ad45becf6204abdf5495f43f8f","note":"Actual LICENSE includes Apache/Mixpanel text despite MIT package metadata; preserve actual files and review vendored sources."}, - {"component":"npm:better-auth@1.7.12","ecosystem":"npm","version":"1.7.12","source":"node_modules/better-auth/LICENSE.md","sha256":"7a07f7d9085cb3157e4377d944988c9712933141b0f7201955ceccfac92abde4"} + {"component":"npm:better-auth@1.7.12","ecosystem":"npm","version":"1.7.12","source":"node_modules/better-auth/LICENSE.md","sha256":"7a07f7d9085cb3157e4377d944988c9712933141b0f7201955ceccfac92abde4"}, + { + "component": "cocoapods:EXApplication@57.0.3", + "ecosystem": "cocoapods", + "version": "57.0.3", + "source": "node_modules/expo-application/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.3.tgz", + "upstreamIntegrity": "sha512-t/CxeLio0NjY9/cOak1tQp62/hAUjzJxmhR4rVUR7K5bb9YoEtfl1ZekTn+Iuh+dYOMYzF0l72ODMSmY3CR1NA==", + "ownerNpmID": "npm:expo-application@57.0.3", + "packagePath": "node_modules/expo-application", + "packageJSONSource": "node_modules/expo-application/package.json", + "packageJSONSHA256": "42a9ae6cde4c85860bd10ec69cea4aa1e74e67c7ea41b6605cc59de580ca51ba", + "podspecSource": "node_modules/expo-application/ios/EXApplication.podspec", + "podspecSHA256": "efd775d5c67d7527e0a7b58e03341392a9b6d72e4d9ea54545bef7bd66e42c5a", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:EXConstants@57.0.22", + "ecosystem": "cocoapods", + "version": "57.0.22", + "source": "node_modules/expo-constants/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.22.tgz", + "upstreamIntegrity": "sha512-+mBjnzMWMKIrQa7BP7LTDRC0W7cI+q12/sZns4HzVQJGzgGoAdRJSTEKiObxIw/3EswKXlESkRob8upiL95Q4A==", + "ownerNpmID": "npm:expo-constants@57.0.22", + "packagePath": "node_modules/expo-constants", + "packageJSONSource": "node_modules/expo-constants/package.json", + "packageJSONSHA256": "0a862025ddfd7f3ab72439208e4561c8a0c362bb0cca4d277968a56c53a8b22e", + "podspecSource": "node_modules/expo-constants/ios/EXConstants.podspec", + "podspecSHA256": "0cab848942ac2cec3b199aaba1d60f820c2d26c70e933114b26b303beb755d25", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoAsset@57.0.18", + "ecosystem": "cocoapods", + "version": "57.0.18", + "source": "node_modules/expo-asset/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.18.tgz", + "upstreamIntegrity": "sha512-zDIQkUr0NM2S7YRj98Zohf/OBuM1q0o4ctxLz1C1oe4/33lHNLjp+2v3qUtBWVwn0zKka8UGADmS8M+83Wi6pw==", + "ownerNpmID": "npm:expo-asset@57.0.18", + "packagePath": "node_modules/expo-asset", + "packageJSONSource": "node_modules/expo-asset/package.json", + "packageJSONSHA256": "39a3accfa83784f2c13b8bb69c28ab8d6feb1f0296c72f073c73f9bdc31b70aa", + "podspecSource": "node_modules/expo-asset/ios/ExpoAsset.podspec", + "podspecSHA256": "1718cbff56334111fd5fc9eda65bd8724bfc4ec7de39b46f260e41cc8db78d7b", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoAudio@57.0.5", + "ecosystem": "cocoapods", + "version": "57.0.5", + "source": "node_modules/expo-audio/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-audio/-/expo-audio-57.0.5.tgz", + "upstreamIntegrity": "sha512-39fX5JC5zDvELB4/eDJPj2XAYRN0W9+K2DfDWm5N3aC5WZ7HRrhupWtT6hbH09NlfYpsRSqYOwEZnocFM42ynQ==", + "ownerNpmID": "npm:expo-audio@57.0.5", + "packagePath": "node_modules/expo-audio", + "packageJSONSource": "node_modules/expo-audio/package.json", + "packageJSONSHA256": "414f4117da273107eb8617f99caa7e78769a490ff4ffc22412e86a8246fbd98c", + "podspecSource": "node_modules/expo-audio/ios/ExpoAudio.podspec", + "podspecSHA256": "8b88c4f2d65206464d825a8e702ae7294a87e95fcd54d98e6d1995bc1892396f", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoBlur@57.0.3", + "ecosystem": "cocoapods", + "version": "57.0.3", + "source": "node_modules/expo-blur/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-blur/-/expo-blur-57.0.3.tgz", + "upstreamIntegrity": "sha512-uyBeJwxIuXzWXXNrnRub5pcF8VzQsiOz0VBTWDpMG2UztmaFyfAz6pI/GCwRtWpHhiOpLoJj79WLdg1GMB+9cg==", + "ownerNpmID": "npm:expo-blur@57.0.3", + "packagePath": "node_modules/expo-blur", + "packageJSONSource": "node_modules/expo-blur/package.json", + "packageJSONSHA256": "e4887ca7c2d5e0e4a5aca2dac31ef4a2b0fc0959b7264b8ff4ffd30960f81704", + "podspecSource": "node_modules/expo-blur/ios/ExpoBlur.podspec", + "podspecSHA256": "7991a98e7fb30134127ba3977149dcda0f32b10008e75d78abdc8f914255de26", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoCrypto@57.0.4", + "ecosystem": "cocoapods", + "version": "57.0.4", + "source": "node_modules/expo-crypto/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.4.tgz", + "upstreamIntegrity": "sha512-LqMMs/N6wvwZyw4deVmcl3AgXgqMwCVQ9WyWf2eaWyrk99TXSKk5da3QspiLOOjnn5Qv9f8C50cDNuoyLThpdw==", + "ownerNpmID": "npm:expo-crypto@57.0.4", + "packagePath": "node_modules/expo-crypto", + "packageJSONSource": "node_modules/expo-crypto/package.json", + "packageJSONSHA256": "c85d574f13742a3e47d84b7551108461814cfcec0ac82257cd852c205cc502c5", + "podspecSource": "node_modules/expo-crypto/ios/ExpoCrypto.podspec", + "podspecSHA256": "4bad7960cae20fbb95ac78251ba991975975374c25827531f628bd4a17da1266", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoDocumentPicker@57.0.3", + "ecosystem": "cocoapods", + "version": "57.0.3", + "source": "node_modules/expo-document-picker/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.3.tgz", + "upstreamIntegrity": "sha512-XlxdZ0zzv44JRD8O15DISgIO8FTaZ581oc4kQp6MJoR7vdahjcY+2qCuDt5k1J8/lc0YR+bXon/v81norciuxw==", + "ownerNpmID": "npm:expo-document-picker@57.0.3", + "packagePath": "node_modules/expo-document-picker", + "packageJSONSource": "node_modules/expo-document-picker/package.json", + "packageJSONSHA256": "dfaeb89f6fa29ffc80b8a5694867f2a4ba3b0a9f0a0fcff95e101b9115b97ce7", + "podspecSource": "node_modules/expo-document-picker/ios/ExpoDocumentPicker.podspec", + "podspecSHA256": "97963d0c1396023323a9dfcec88cd70d19bdef049f4554ea837ea0453bfa0bc8", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoDomWebView@57.0.1", + "ecosystem": "cocoapods", + "version": "57.0.1", + "source": "node_modules/@expo/dom-webview/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/@expo/dom-webview/-/dom-webview-57.0.1.tgz", + "upstreamIntegrity": "sha512-lAKsME4SAq+8sf56oN0DX5TBYyruupoRxbWbD2xf9RnKY8y6x8eb9LCE5pxSN0qyWdqnp+0wmyWzDkKboThKAw==", + "ownerNpmID": "npm:@expo/dom-webview@57.0.1", + "packagePath": "node_modules/@expo/dom-webview", + "packageJSONSource": "node_modules/@expo/dom-webview/package.json", + "packageJSONSHA256": "d9a45419e57a87310ef71121b1959d7fc1a21e919b0337f2b542947b1606213d", + "podspecSource": "node_modules/@expo/dom-webview/ios/ExpoDomWebView.podspec", + "podspecSHA256": "7b4d6348467cd96e77fee59cb8f572f40543bbfc064f81f310c6c98125d52504", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoFont@57.0.4", + "ecosystem": "cocoapods", + "version": "57.0.4", + "source": "node_modules/expo-font/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.4.tgz", + "upstreamIntegrity": "sha512-7WOMC2oA6xCfOm8bbofqWn2bIqyWFm7YLEnpc/gA1MJH0Ll0TfT/3+CqRkoW2eNRWMS8sxrz5WukUmSvmNquHA==", + "ownerNpmID": "npm:expo-font@57.0.4", + "packagePath": "node_modules/expo-font", + "packageJSONSource": "node_modules/expo-font/package.json", + "packageJSONSHA256": "ad2dd07d792d6a6f3fd0f374e459356c2fbcc749645175743ab32bb4be56c5f7", + "podspecSource": "node_modules/expo-font/ios/ExpoFont.podspec", + "podspecSHA256": "e9b906e51f162670d3364873ba9b0d4d4b281287b2482012f53bd9648172dfa0", + "sourceKind": "podspec", + "public": true + }, + { + "component": "cocoapods:ExpoGlassEffect@57.0.4", + "ecosystem": "cocoapods", + "version": "57.0.4", + "source": "node_modules/expo-glass-effect/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-glass-effect/-/expo-glass-effect-57.0.4.tgz", + "upstreamIntegrity": "sha512-/AG2Kljbqfgxu+GchI2YFIbxiF9cPsIpCy4iNDzlRiZk9rvR6BwRfB6gfQVu3V2ExaoGf2h5IsEnqVI5MYNBAg==", + "ownerNpmID": "npm:expo-glass-effect@57.0.4", + "packagePath": "node_modules/expo-glass-effect", + "packageJSONSource": "node_modules/expo-glass-effect/package.json", + "packageJSONSHA256": "c46c81fb7a89465298faf465f7f27ec2ff15270e8b9ae141b274a4fb8d725cca", + "podspecSource": "node_modules/expo-glass-effect/ios/ExpoGlassEffect.podspec", + "podspecSHA256": "a5501bda5666520f89eb535a3bcdb3d38b013b650c2b6ac652dd9d3205183c8d", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoHaptics@57.0.3", + "ecosystem": "cocoapods", + "version": "57.0.3", + "source": "node_modules/expo-haptics/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-57.0.3.tgz", + "upstreamIntegrity": "sha512-qucUXtwtJdtECKSQK5D6TQBc/0SxKj7s5wQ4mrfSxhZsHrS9a4rcxINl/gbruMaY9spTXStco32ST4pV0vXmxw==", + "ownerNpmID": "npm:expo-haptics@57.0.3", + "packagePath": "node_modules/expo-haptics", + "packageJSONSource": "node_modules/expo-haptics/package.json", + "packageJSONSHA256": "25df027b20e1889c5d7e8f4d261dead35d45cecf3c8a22e9da2df4aa2f7f336c", + "podspecSource": "node_modules/expo-haptics/ios/ExpoHaptics.podspec", + "podspecSHA256": "105a626bf7ef749ec4ab808388be1d0dd88a61ef77831d4c32f8ec44d497a086", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoImageManipulator@57.0.21", + "ecosystem": "cocoapods", + "version": "57.0.21", + "source": "node_modules/expo-image-manipulator/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-57.0.21.tgz", + "upstreamIntegrity": "sha512-NZeNvZxBNf/PK97Df5zORmtqRR/LovkA+lHdRLm1IJFiVKjs4xr+Ib6/jWhDxn04VOX3TJswCQyu4SCxkgMe4Q==", + "ownerNpmID": "npm:expo-image-manipulator@57.0.21", + "packagePath": "node_modules/expo-image-manipulator", + "packageJSONSource": "node_modules/expo-image-manipulator/package.json", + "packageJSONSHA256": "8f0baca252fe2fdd5fa38c3b49b8f822bd179c07b11efa66fb8945d91db0033b", + "podspecSource": "node_modules/expo-image-manipulator/ios/ExpoImageManipulator.podspec", + "podspecSHA256": "d138a510c3d87bb84b5f6294e36621961552984103f25ef28d83745052dbad8e", + "sourceKind": "podspec", + "public": true + }, + { + "component": "cocoapods:ExpoImagePicker@57.0.20", + "ecosystem": "cocoapods", + "version": "57.0.20", + "source": "node_modules/expo-image-picker/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.20.tgz", + "upstreamIntegrity": "sha512-plgcGuMZMWqDchjXQW6S/WepYLB5KqfbZOzwC6xe0+j5IhY4ipvVuRZcXeP/0NgKgU0Jiu/XoTK4Bi8crtZ+sQ==", + "ownerNpmID": "npm:expo-image-picker@57.0.20", + "packagePath": "node_modules/expo-image-picker", + "packageJSONSource": "node_modules/expo-image-picker/package.json", + "packageJSONSHA256": "f0d275ca56b5ca770f8ebc8cebfd1e7bf2a2dfada9ce881a2c1f1fa51367004c", + "podspecSource": "node_modules/expo-image-picker/ios/ExpoImagePicker.podspec", + "podspecSHA256": "1aa833709b17c1923af7d98b4fc443960e4a4712cbe95f49bdf89ef0425d85a2", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoKeepAwake@57.0.2", + "ecosystem": "cocoapods", + "version": "57.0.2", + "source": "node_modules/expo-keep-awake/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.2.tgz", + "upstreamIntegrity": "sha512-GqgH746wtJImmsbyHmCQoi7cOslKuWQBbJHXr53S35Bpf5UvxCAztvnitUEd7D0QvFN2rvkOhylbZpgZZfBLlQ==", + "ownerNpmID": "npm:expo-keep-awake@57.0.2", + "packagePath": "node_modules/expo-keep-awake", + "packageJSONSource": "node_modules/expo-keep-awake/package.json", + "packageJSONSHA256": "4d5745b10c29e278ec976ca711c0a4fa9c07db0b3fe97108b765c1e72f840cf5", + "podspecSource": "node_modules/expo-keep-awake/ios/ExpoKeepAwake.podspec", + "podspecSHA256": "01f9bed1472f12a454bb1c976cb52925f1d0cf71a87372646b2ceb24af8a55dc", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoLinking@57.0.13", + "ecosystem": "cocoapods", + "version": "57.0.13", + "source": "node_modules/expo-linking/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.13.tgz", + "upstreamIntegrity": "sha512-1cjN7Ely0FZqlZItfPYjl6jjrCFLRevrIE/H5XhA6tgDm9uhTaSyJQXyftgt2FvJZ7liheDft7WMnPB9Uktx+A==", + "ownerNpmID": "npm:expo-linking@57.0.13", + "packagePath": "node_modules/expo-linking", + "packageJSONSource": "node_modules/expo-linking/package.json", + "packageJSONSHA256": "4c701ffeba5cb9a1677944013c7a79782050fae28b32ce8adc18f1c9318ff216", + "podspecSource": "node_modules/expo-linking/ios/ExpoLinking.podspec", + "podspecSHA256": "9ff9686452eb2ecede43c3af5751bc045f1286e1ef2366d8d8e9ee2af04be0c7", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoModulesCore@57.0.18", + "ecosystem": "cocoapods", + "version": "57.0.18", + "source": "node_modules/expo-modules-core/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.18.tgz", + "upstreamIntegrity": "sha512-ovB7C+GVQTlKAOIsOIHJVVXN9Mn0ljf2SYBS+afrRaGqq+HZEn1D3o+HReIVeH1NjCn/xDBb7I4HjldHs6luZA==", + "ownerNpmID": "npm:expo-modules-core@57.0.18", + "packagePath": "node_modules/expo-modules-core", + "packageJSONSource": "node_modules/expo-modules-core/package.json", + "packageJSONSHA256": "e2041e04ed5a09e29d9d69eb28b4f216c88c65a8b222760076e7eadb50e8c73b", + "podspecSource": "node_modules/expo-modules-core/ExpoModulesCore.podspec", + "podspecSHA256": "b8259e2770ded8730eec301be7ac22f1a8624c85dcd4bfeaeaf01a0bcd0c4887", + "sourceKind": "podspec", + "public": true + }, + { + "component": "cocoapods:ExpoModulesJSI@57.1.0", + "ecosystem": "cocoapods", + "version": "57.1.0", + "source": "node_modules/expo-modules-jsi/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-57.1.0.tgz", + "upstreamIntegrity": "sha512-a5ckeHfnbYfonhcHGkM0EU/a0Keh+/OufXy/HyFS+spr5Ib0N4Oh1ZsSYFQhp5xdOUFkVofKRhfH+VmcccIIpA==", + "ownerNpmID": "npm:expo-modules-jsi@57.1.0", + "packagePath": "node_modules/expo-modules-jsi", + "packageJSONSource": "node_modules/expo-modules-jsi/package.json", + "packageJSONSHA256": "bf6cd5eece4eb71cd4fa9ddf2592d4a1a3386b6cfef481d6904a0c897ee86bae", + "podspecSource": "node_modules/expo-modules-jsi/apple/ExpoModulesJSI.podspec", + "podspecSHA256": "ff84b90c5b7c9e5945deb3a9f140bf675e60eb7f4d7bdfb6ab8f3c320d0cc10b", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoModulesWorklets@57.0.18", + "ecosystem": "cocoapods", + "version": "57.0.18", + "source": "node_modules/expo-modules-core/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.18.tgz", + "upstreamIntegrity": "sha512-ovB7C+GVQTlKAOIsOIHJVVXN9Mn0ljf2SYBS+afrRaGqq+HZEn1D3o+HReIVeH1NjCn/xDBb7I4HjldHs6luZA==", + "ownerNpmID": "npm:expo-modules-core@57.0.18", + "packagePath": "node_modules/expo-modules-core", + "packageJSONSource": "node_modules/expo-modules-core/package.json", + "packageJSONSHA256": "e2041e04ed5a09e29d9d69eb28b4f216c88c65a8b222760076e7eadb50e8c73b", + "podspecSource": "node_modules/expo-modules-core/ExpoModulesWorklets.podspec", + "podspecSHA256": "9e25c99d9193663d944fba43798c3b3c93823d67ebb5f6af7251a007067cc95a", + "sourceKind": "podspec", + "public": true + }, + { + "component": "cocoapods:ExpoNetwork@57.0.2", + "ecosystem": "cocoapods", + "version": "57.0.2", + "source": "node_modules/expo-network/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-network/-/expo-network-57.0.2.tgz", + "upstreamIntegrity": "sha512-BfK1kdftZcM6o9o1FAG7/y0l6vWX0n8td0M5lwkvaMeRXZ9LkRwOi9CQXwvsMTQGQiPwtQXgwiAze13cqjt75g==", + "ownerNpmID": "npm:expo-network@57.0.2", + "packagePath": "node_modules/expo-network", + "packageJSONSource": "node_modules/expo-network/package.json", + "packageJSONSHA256": "5e162f03c47f3ad359b6cdee6d97708d935e4013452649a90559ed4870be9b74", + "podspecSource": "node_modules/expo-network/ios/ExpoNetwork.podspec", + "podspecSHA256": "70b95c3140930354a1c0479000be95358ace9893072d7279600ee53c32b733fb", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoNotifications@57.0.20", + "ecosystem": "cocoapods", + "version": "57.0.20", + "source": "node_modules/expo-notifications/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-57.0.20.tgz", + "upstreamIntegrity": "sha512-T1xaydHHlsOt6sA9DlSC74Kt+yQb9vwM79dBcac5U/5GPdU7e80LleDleQbMgUXO4c2W/ZKBCljDONyvOqteXQ==", + "ownerNpmID": "npm:expo-notifications@57.0.20", + "packagePath": "node_modules/expo-notifications", + "packageJSONSource": "node_modules/expo-notifications/package.json", + "packageJSONSHA256": "b58229e6f32c87f3246b18795c69cad42de6bbba318404871ab8a3d5cf5ab17f", + "podspecSource": "node_modules/expo-notifications/ios/ExpoNotifications.podspec", + "podspecSHA256": "20d85106721d50f113a0122969556134e06159643a05ccf087e4ed3183bc737c", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoSecureStore@57.0.4", + "ecosystem": "cocoapods", + "version": "57.0.4", + "source": "node_modules/expo-secure-store/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.4.tgz", + "upstreamIntegrity": "sha512-7zBA1RThURTXdP4w65kBzZNbcSsfArqVd+p+ETAnPRWXO8J296Qd0YaCVyRpJ0HSwlCnGdvOnqO+NgSreHsOhA==", + "ownerNpmID": "npm:expo-secure-store@57.0.4", + "packagePath": "node_modules/expo-secure-store", + "packageJSONSource": "node_modules/expo-secure-store/package.json", + "packageJSONSHA256": "de06e2429d623c0bc0120dedee1ce3285a9852a48cf3f70aa8ca6b47b1370a4b", + "podspecSource": "node_modules/expo-secure-store/ios/ExpoSecureStore.podspec", + "podspecSHA256": "e52caabb236489f0e2cfe8014272d30cb9ba0fac1ab97b53487c5fd4b84763ea", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoSharing@57.0.21", + "ecosystem": "cocoapods", + "version": "57.0.21", + "source": "node_modules/expo-sharing/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-57.0.21.tgz", + "upstreamIntegrity": "sha512-7Kv3ai5kdSZ/3Ok05AOWUcXSfDrqeQ5rKpWztIvi86A4WxQCed+DV4AtnhfEwZ0ZeFxJj2Au/mjfQ06J4YK8HA==", + "ownerNpmID": "npm:expo-sharing@57.0.21", + "packagePath": "node_modules/expo-sharing", + "packageJSONSource": "node_modules/expo-sharing/package.json", + "packageJSONSHA256": "dd9d2ad5192d320b6db304752951c186f44540ac3523cdec010f2b7983daa4dc", + "podspecSource": "node_modules/expo-sharing/ios/ExpoSharing.podspec", + "podspecSHA256": "921d42ca9fa09e129074d0e379349b8492295e6fb207d55479d66ec9f43f4e7c", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoSpeechRecognition@57.0.0", + "ecosystem": "cocoapods", + "version": "57.0.0", + "source": "node_modules/expo-speech-recognition/LICENSE", + "sha256": "d3ee89fbcdb9592f396dcfe657aae382152ac50398f1261ff2ea215b7485ddc8", + "upstream": "https://registry.npmjs.org/expo-speech-recognition/-/expo-speech-recognition-57.0.0.tgz", + "upstreamIntegrity": "sha512-eUHnlNPhYemXIYi2y/bGigDjefE8y0hySAx+tAbaKwEVStYJxJKPHDYWll0z3ELHFLzdyx22V8qhZic9Q6yhNA==", + "ownerNpmID": "npm:expo-speech-recognition@57.0.0", + "packagePath": "node_modules/expo-speech-recognition", + "packageJSONSource": "node_modules/expo-speech-recognition/package.json", + "packageJSONSHA256": "fd093c3d2f102b13de0924def461c8fef68f324af4eb8c786acbe876ac16f233", + "podspecSource": "node_modules/expo-speech-recognition/ios/ExpoSpeechRecognition.podspec", + "podspecSHA256": "6ad9768daa35f2bd37429401e583cdee14892f229a13bd05106a66b43269ce9a", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoSQLite@57.0.4", + "ecosystem": "cocoapods", + "version": "57.0.4", + "source": "node_modules/expo-sqlite/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-57.0.4.tgz", + "upstreamIntegrity": "sha512-3N/bnDx4+kWEC9GABxKPLe/6RHTZJR/7DNT5tz/cYi8f7emign3ooavXnJn0+JPBA0JhHmsdDPrmC+CtT86Jzg==", + "ownerNpmID": "npm:expo-sqlite@57.0.4", + "packagePath": "node_modules/expo-sqlite", + "packageJSONSource": "node_modules/expo-sqlite/package.json", + "packageJSONSHA256": "dfbcb74ec3dae858221cde06efa60f09b838f56f79260c97b806d339a5fa00ec", + "podspecSource": "node_modules/expo-sqlite/ios/ExpoSQLite.podspec", + "podspecSHA256": "1c6e72b1a331a953e0a53948bf13e48924f9e7f4554aeedd8349798305d7c1b6", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoSymbols@57.0.3", + "ecosystem": "cocoapods", + "version": "57.0.3", + "source": "node_modules/expo-symbols/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-57.0.3.tgz", + "upstreamIntegrity": "sha512-uuK5yHLVR7kExUCrO/ItBvp/kp1maQ9Nkb6KvNy5ZUQnN0NMKwuhJ4GvbfS9N3ACmvCjTT4gKvg4yH5UuKIYNA==", + "ownerNpmID": "npm:expo-symbols@57.0.3", + "packagePath": "node_modules/expo-symbols", + "packageJSONSource": "node_modules/expo-symbols/package.json", + "packageJSONSHA256": "0a7275a38eba3613c7e527d1a6ca5f28d6def11fafc57d01a317a1f1f3613cc1", + "podspecSource": "node_modules/expo-symbols/ios/ExpoSymbols.podspec", + "podspecSHA256": "17bb160eceea3471b4362d8d04434473e74cd9abda1bebc3102a5688a0e9a3b4", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoSystemUI@57.0.4", + "ecosystem": "cocoapods", + "version": "57.0.4", + "source": "node_modules/expo-system-ui/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-57.0.4.tgz", + "upstreamIntegrity": "sha512-z//rxQJzhTNA1PcKrUtWmrd21jAS3nEDu1b0n8B/7LTTR933OcsZYP1HnbaZifuo2ZH/h7nce2Ow61WZ1Ig2bg==", + "ownerNpmID": "npm:expo-system-ui@57.0.4", + "packagePath": "node_modules/expo-system-ui", + "packageJSONSource": "node_modules/expo-system-ui/package.json", + "packageJSONSHA256": "7e37b44953b3fd0443c4cd9c9ce824f6c34f4755d14d0194925394d8c46a9297", + "podspecSource": "node_modules/expo-system-ui/ios/ExpoSystemUI.podspec", + "podspecSHA256": "b14fbdf7603a39ccf20c05b97ef524035bb30bde0986f342ef8ebbedf5146cdf", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoWebBrowser@57.0.3", + "ecosystem": "cocoapods", + "version": "57.0.3", + "source": "node_modules/expo-web-browser/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-57.0.3.tgz", + "upstreamIntegrity": "sha512-+ecvhyS/PdWkpv4ai6FF1Trd6Y/4kn/eQ3O86mie2ecU3w+I8bKZh13kppY/jw1uSL7yikt03L5W/iiHojrFwQ==", + "ownerNpmID": "npm:expo-web-browser@57.0.3", + "packagePath": "node_modules/expo-web-browser", + "packageJSONSource": "node_modules/expo-web-browser/package.json", + "packageJSONSHA256": "de9bdf86a6c1c290d193860b557ef0a6878396b5d6440df2949086655fe0273a", + "podspecSource": "node_modules/expo-web-browser/ios/ExpoWebBrowser.podspec", + "podspecSHA256": "cf6be6b18e7780214af9a3e81d016324e78355a3f3cd3c7ab79ec35c2dfff0d8", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ExpoWidgets@57.0.26", + "ecosystem": "cocoapods", + "version": "57.0.26", + "source": "node_modules/expo-widgets/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-widgets/-/expo-widgets-57.0.26.tgz", + "upstreamIntegrity": "sha512-gXVYxNeYR08QvohfnyyqTVxGgsF07PtK0Pg/i4vdFs2akT6mf/m4/vc3Co1F5lMDzbQSTpZyCDMxVOImv3F46g==", + "ownerNpmID": "npm:expo-widgets@57.0.26", + "packagePath": "node_modules/expo-widgets", + "packageJSONSource": "node_modules/expo-widgets/package.json", + "packageJSONSHA256": "fdf67faedfe6c0df6dd8f3ec776d7685ba5ce9bdf786534de35cd73801e53013", + "podspecSource": "node_modules/expo-widgets/ios/ExpoWidgets.podspec", + "podspecSHA256": "fe7fa8b631006c117ed9b7a079a65c362bca470df8c8e171d1c8e65b204d362e", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:FBLazyVector@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/Libraries/FBLazyVector/FBLazyVector.podspec", + "podspecSHA256": "45eaa07214c2fa1aa5eaaa1786fac9315b76b196e11b6c57fcc9db0b962f84dd", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:RCTDeprecation@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation/RCTDeprecation.podspec", + "podspecSHA256": "572fe3cb6685e2ccebb01e49f32282cc58f15da81b88002287593444035f9e09", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:RCTRequired@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/Libraries/Required/RCTRequired.podspec", + "podspecSHA256": "9092287ef4b1bb2ddef88bc60113055a4a587278273125ad51b8055d360b080e", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:RCTSwiftUI@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactApple/RCTSwiftUI/RCTSwiftUI.podspec", + "podspecSHA256": "651180ecc69e26a3370fa68ff532df9891071859de5178831c43a2608e9bd57b", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:RCTSwiftUIWrapper@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactApple/RCTSwiftUIWrapper/RCTSwiftUIWrapper.podspec", + "podspecSHA256": "0e1bbcbb77a846c3f92f3c61925a5408ea84e4d9de377b051ff3cf871e1b71df", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:RCTTypeSafety@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/Libraries/TypeSafety/RCTTypeSafety.podspec", + "podspecSHA256": "1813284bebc1095d08458f0c5958e8ec29ff656d5677e8c0d1b38e73ba30bfc7", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-callinvoker@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/callinvoker/React-callinvoker.podspec", + "podspecSHA256": "0849a8edd2fdaf2ce6f31684dcf39f0243533b6af0b70d4f098425302c2fc7b9", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-Core-prebuilt@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/React-Core-prebuilt.podspec", + "podspecSHA256": "75c94c85de947ed4cc0b63b6031d8a36876680b2ead00cac35263c3073d92e8a", + "sourceKind": "podspec", + "public": true + }, + { + "component": "cocoapods:React-CoreModules@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/React/CoreModules/React-CoreModules.podspec", + "podspecSHA256": "654a2d85ffee5091377bda278c4583c8ce028f5949e35cb6468fd058adaf61d0", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-cxxreact@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/cxxreact/React-cxxreact.podspec", + "podspecSHA256": "48d45caa0b4bdae583328d91e83cfddee63add9a90bdf4e02b3441b673a8b596", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-debug@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/debug/React-debug.podspec", + "podspecSHA256": "691efcd70ea5f7f5c06e3a658f4cefaa0e97d1ca0c6127b7932bce5ea6805b36", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-defaultsnativemodule@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/nativemodule/defaults/React-defaultsnativemodule.podspec", + "podspecSHA256": "7d0c637a1860b4b5e95661d6fca3935f2ffdf77c3e4709c1a9945b3a75659c74", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-domnativemodule@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/nativemodule/dom/React-domnativemodule.podspec", + "podspecSHA256": "ac4afb3d18be45b8ede907d32c7f8fc919e504867cf97305187a23cb8b01d308", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-Fabric@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/React-Fabric.podspec", + "podspecSHA256": "dd725906d2f32d907c627ee307b2e40cf4d96cb7bf563bd9a9a3f1d41f337f08", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-FabricComponents@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/React-FabricComponents.podspec", + "podspecSHA256": "8e27953501ff506ca325ab653433f9820d3fde47e6a9dfc8ad278433358985d1", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-FabricImage@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/React-FabricImage.podspec", + "podspecSHA256": "26457e6e29f759bca49f8a8cc56124c26259ad9be9384d0a26fb1c8297bd9145", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-featureflags@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/featureflags/React-featureflags.podspec", + "podspecSHA256": "77e7190a785a18caf543c8185d0753d520edb508acb84e90175b0f0aeff20a7e", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-featureflagsnativemodule@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/nativemodule/featureflags/React-featureflagsnativemodule.podspec", + "podspecSHA256": "f0f05355381411c9f9dfb67daf3e43c38eb10b28de9648cbbb07e0afb9980ed3", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-graphics@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/renderer/graphics/React-graphics.podspec", + "podspecSHA256": "0d8dbdec5e7161ac7ab8b5a11d3300f9a5e76cc9d3dc6c03fc147fdac86f2480", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-hermes@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/hermes/React-hermes.podspec", + "podspecSHA256": "c0769799009387eb61e32e751d1ebee1d10ccd74b2609338b4b138600cee9bfc", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-idlecallbacksnativemodule@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks/React-idlecallbacksnativemodule.podspec", + "podspecSHA256": "8060de6df4bfe3834ac6b5c287ed2a04d6859127b83f45e92b24e898518d6b0d", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-ImageManager@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios/React-ImageManager.podspec", + "podspecSHA256": "0f0d8f7a2cf7c58d148c2d2e5eb2f99b02dd942a6da4e1e876d465de37210195", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-intersectionobservernativemodule@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/nativemodule/intersectionobserver/React-intersectionobservernativemodule.podspec", + "podspecSHA256": "40c167df44c18a79a9c1d0611a9c085826354247c205480d8d717a5f75861dfe", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-jserrorhandler@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/jserrorhandler/React-jserrorhandler.podspec", + "podspecSHA256": "a60b0f0990350ee4f59996a03fde90d7656305c65c26c938c7561b1969354e80", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-jsi@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/jsi/React-jsi.podspec", + "podspecSHA256": "d313670e82ed88dede6ef6f7673cfa0d11c7dda52ca0b4da0ce00055bd59be60", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-jsiexecutor@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/jsiexecutor/React-jsiexecutor.podspec", + "podspecSHA256": "c0aa98c8e265d326937a5fb6f0cb6ef06f076049033395054d79a1bf4292cd56", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-jsinspector@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/jsinspector-modern/React-jsinspector.podspec", + "podspecSHA256": "4be5e72336e03f32fd55cbad30722be909f82a783d42b17c109afa3c68ead764", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-jsinspectorcdp@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/jsinspector-modern/cdp/React-jsinspectorcdp.podspec", + "podspecSHA256": "35f306217b0c682b20cb61152f5ab138edc4a775219cc3a061357285bcf0da55", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-jsinspectornetwork@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/jsinspector-modern/network/React-jsinspectornetwork.podspec", + "podspecSHA256": "b7c8deb68c68ca03072ceee353a6af60d88786fefe6c04d3193bb8b48824e427", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-jsinspectortracing@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/jsinspector-modern/tracing/React-jsinspectortracing.podspec", + "podspecSHA256": "79441a7056d27313322021a5ca1abacabf72d44afffc5b674a9668526d31ca78", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-jsitooling@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/jsitooling/React-jsitooling.podspec", + "podspecSHA256": "7c03c8139e3cbb6b20f73fbfba8c970c19566d6c7abb981aa50d523779618015", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-jsitracing@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/hermes/executor/React-jsitracing.podspec", + "podspecSHA256": "5b788c2bdcd8c5b64a4b2c8b4ab7f8bb31dff314ec8887fece4bde2a6e18eea8", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-logger@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/logger/React-logger.podspec", + "podspecSHA256": "72d25b4f44182ba1a5ce933653a24a9a287be4a22b6b5ba2467de601e600b3fb", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-Mapbuffer@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/React-Mapbuffer.podspec", + "podspecSHA256": "384b9913ed26998748b4e2e107d6b19035d6ff939dcba3e1b3a9610267eb81f2", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-microtasksnativemodule@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/nativemodule/microtasks/React-microtasksnativemodule.podspec", + "podspecSHA256": "20ea373d4c882b1ff39afba5cdb1c5d1657b0c081470baa77fc355eace1022b7", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-mutationobservernativemodule@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver/React-mutationobservernativemodule.podspec", + "podspecSHA256": "e556b08b4936bacd20614efee59f54ab259a649d735b7ccb0d353ff983ff1608", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-NativeModulesApple@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios/React-NativeModulesApple.podspec", + "podspecSHA256": "af7d1f0b2aac2b059d9737fa1c01144abbb726d05e949002a2643c09a4cae65d", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-networking@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/networking/React-networking.podspec", + "podspecSHA256": "c746d41d3018a9c1d052a07fb47b592f990b6a2429d716f1340aa6d79f5a3d7e", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-oscompat@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/oscompat/React-oscompat.podspec", + "podspecSHA256": "ee362e43a51fb5d8b7c93fa3d14872cde4c3da7e39aee580226e89dfe0ccf320", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-perflogger@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/reactperflogger/React-perflogger.podspec", + "podspecSHA256": "818c3b466e9ec5fc6c988cfe87872a4c979939698f9aff85603008b5536cc69c", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-performancecdpmetrics@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/performance/cdpmetrics/React-performancecdpmetrics.podspec", + "podspecSHA256": "383d30666e276612d6040605e64aa3682b319c52f770b52413520a5d88c33df1", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-performancetimeline@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/performance/timeline/React-performancetimeline.podspec", + "podspecSHA256": "96004d39d66e9cb2cf448256fc840e215d92e558f0e527afa8e6ce23ac0c7ffb", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-RCTActionSheet@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/Libraries/ActionSheetIOS/React-RCTActionSheet.podspec", + "podspecSHA256": "ae8f95831b9c5e67733b0745bce6ab14b9f614fe942730abf73433d5dd5aaa4c", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-RCTAnimation@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/Libraries/NativeAnimation/React-RCTAnimation.podspec", + "podspecSHA256": "f0f67b5341fa206dbc8eb8c000bf89f23fc96c5a6f8460e19ed2ef6ac960f46a", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-RCTAppDelegate@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/Libraries/AppDelegate/React-RCTAppDelegate.podspec", + "podspecSHA256": "f2f9dfb16e1a610828ff9155e142f59b9689b34b2d84217064bed0a5fa8c6f9e", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-RCTBlob@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/Libraries/Blob/React-RCTBlob.podspec", + "podspecSHA256": "21dd1ca7570b52d61daa7827c7f6f4e3ece42722e56bfb1c48c1d9a5362d3f2b", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-RCTFabric@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/React/React-RCTFabric.podspec", + "podspecSHA256": "9c9e78513d170f64bf3d5dbce73c810b2cc42e9effcf14a26164235a74b66fd7", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-RCTFBReactNativeSpec@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/React/React-RCTFBReactNativeSpec.podspec", + "podspecSHA256": "8ff771cf918c94017b53171829ccc2f304a82a3556499ceb39284bc17295663e", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-RCTImage@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/Libraries/Image/React-RCTImage.podspec", + "podspecSHA256": "85e0f8b9177d3a4a913a8e297054013d51c639ffbea6f0429fbbf0e3ba5fb11f", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-RCTLinking@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/Libraries/LinkingIOS/React-RCTLinking.podspec", + "podspecSHA256": "399f454e4f92f40c0ccb4a5937a86f7a3cc47062cb520fc440e5e1ae9b2495d1", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-RCTNetwork@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/Libraries/Network/React-RCTNetwork.podspec", + "podspecSHA256": "686db3a99d7ebe76da07bf33faaf85be4539d0272d3d4551c3db04f1b7078c7c", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-RCTRuntime@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/React/Runtime/React-RCTRuntime.podspec", + "podspecSHA256": "651da05b016101a7cfd6581b6be25da8f279b21ee107f36a4d2ee44ff29e26db", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-RCTSettings@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/Libraries/Settings/React-RCTSettings.podspec", + "podspecSHA256": "c2e761d1163778acab786d717ef890dee2640a77aa55a742f52bbecdf4563e1b", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-RCTText@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/Libraries/Text/React-RCTText.podspec", + "podspecSHA256": "13f40286f24c7fd4e5c4a274675ab902ea4250e805a83baf455f5f0335681e7e", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-RCTVibration@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/Libraries/Vibration/React-RCTVibration.podspec", + "podspecSHA256": "3975e69920a5d4a1db93d0f2b5b05b83189b289d90284a7917906e1b39cf38ae", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-rendererconsistency@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/renderer/consistency/React-rendererconsistency.podspec", + "podspecSHA256": "b37debf97ed80a1cbcb0617111571e314b225ff607af614cd7ca1dd5d805b5a9", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-renderercss@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/renderer/css/React-renderercss.podspec", + "podspecSHA256": "d106d23ba18f67c62d05dad31e7c6c5f6aa6270a901227351d99bb5c50001c78", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-rendererdebug@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/renderer/debug/React-rendererdebug.podspec", + "podspecSHA256": "e3811e6d2674516c745fb9d3862caf05e774282076922136c9e89f5a2d2c4ec1", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-RuntimeApple@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/runtime/platform/ios/React-RuntimeApple.podspec", + "podspecSHA256": "bceef980337797a2bb696ae01c87c6481c1052cd686536a0c8ba07f69dc60b1d", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-RuntimeCore@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/runtime/React-RuntimeCore.podspec", + "podspecSHA256": "2bc4eee8c4e438c162cd8e7326f5a95cbf720771699229dfc290673c1e1460c2", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-runtimeexecutor@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/runtimeexecutor/React-runtimeexecutor.podspec", + "podspecSHA256": "8d578ebf93a0ea92dafe10bb894712383dccee4b5d0d648930d137fdb63b9a22", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-RuntimeHermes@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/runtime/React-RuntimeHermes.podspec", + "podspecSHA256": "239b1d4b3440ecfe79b2e5f64762e34bc94581d4d6cd0fc4398d4922a195ab7d", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-runtimescheduler@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/renderer/runtimescheduler/React-runtimescheduler.podspec", + "podspecSHA256": "f8cacf1f410040da40f27995a4eef9dd3a22298f5e94e03bbabb1b2632ff1d50", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-timing@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/timing/React-timing.podspec", + "podspecSHA256": "4675aa157ea8c09e19a9ecbd271367f504cfabf885672b7eb7072ccbed0d6ede", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-utils@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/utils/React-utils.podspec", + "podspecSHA256": "b6a8c0d5c4c381a93c296bb034c87343857fa5e57cd3481b8ec3039307e298f3", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-viewtransitionnativemodule@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/nativemodule/viewtransition/React-viewtransitionnativemodule.podspec", + "podspecSHA256": "f1e03c3c9877bbf971838dae09442d07d3f231d58503f87a9e7b1c39b09d138d", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:React-webperformancenativemodule@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/react/nativemodule/webperformance/React-webperformancenativemodule.podspec", + "podspecSHA256": "dd436b07c4c1d019a74eaefe50a68c51ae1391cb10ea8545affac1f723f7bcb4", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ReactCommon@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/ReactCommon/ReactCommon.podspec", + "podspecSHA256": "87efdbe16b8998bcc0522112a0bf9679443a3efe5a249eeec6002eafebc5f31f", + "sourceKind": "path", + "public": true + }, + { + "component": "cocoapods:ReactNativeDependencies@0.86.3", + "ecosystem": "cocoapods", + "version": "0.86.3", + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "podspecSource": "node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec", + "podspecSHA256": "a487ac5c74d9aef1b3c6fa527e3cf929d62e031ebaf587f4948813b6717ee086", + "sourceKind": "podspec", + "public": true + } ], "retainedAndroidResources": { "META-INF/androidx/annotation/annotation/LICENSE.txt":"809fa1ed21450f59827d1e9aec720bbc4b687434fa22283c6cb5dd82a47ab9c0", diff --git a/scripts/native-notice-inputs.test.mjs b/scripts/native-notice-inputs.test.mjs index 05e52673..8e034c01 100644 --- a/scripts/native-notice-inputs.test.mjs +++ b/scripts/native-notice-inputs.test.mjs @@ -18,13 +18,34 @@ function fixture(run){ const file=(path,body)=>{mkdirSync(join(root,path,'..'),{recursive:true});writeFileSync(join(root,path),typeof body==='string'||Buffer.isBuffer(body)?body:JSON.stringify(body));return join(root,path);}; try{return run({root,file});}finally{rmSync(root,{recursive:true,force:true});} } -function ownLicenseFixture(run){return fixture(({root,file})=>{ - const provenance=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),pins=provenance.knownLicenseSources.filter(value=>value.ownerNpmID),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),lock={PODS:[], 'EXTERNAL SOURCES':{}}; - assert.equal(pins.length,3);file('package-lock.json',{packages:Object.fromEntries(pins.map(pin=>[pin.packagePath,installed.packages[pin.packagePath]]))});mkdirSync(join(root,'ios/Pods/Target Support Files'),{recursive:true}); +const initialOwnComponents=new Set(['cocoapods:ExpoAppleAuthentication@57.0.2','cocoapods:ExpoFileSystem@57.0.7','cocoapods:ExpoAdapterGoogleSignIn@16.1.5']); +function ownLicenseFixture(run,{batch=false}={}){return fixture(({root,file})=>{ + const provenance=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),pins=provenance.knownLicenseSources.filter(value=>value.ownerNpmID&&(batch?!initialOwnComponents.has(value.component):initialOwnComponents.has(value.component))),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),lock={PODS:[], 'EXTERNAL SOURCES':{}}; + assert.equal(pins.length,batch?97:3);file('package-lock.json',{packages:Object.fromEntries(pins.map(pin=>[pin.packagePath,installed.packages[pin.packagePath]]))});mkdirSync(join(root,'ios/Pods/Target Support Files'),{recursive:true}); for(const pin of pins){const name=pin.component.slice('cocoapods:'.length,-pin.version.length-1);lock.PODS.push(name+' ('+pin.version+')');lock['EXTERNAL SOURCES'][name]={[':'+pin.sourceKind]:'../'+(pin.sourceKind==='path'?dirname(pin.podspecSource):pin.podspecSource)};for(const path of [pin.packageJSONSource,pin.podspecSource,pin.source])file(path,readFileSync(new URL('../'+path,import.meta.url)));file('ios/Pods/'+name+'/source.m','Synthetic alternate Pod root for source-form negatives');if(pin.sourceKind==='podspec')for(const arch of ['ios-arm64','ios-arm64_x86_64-simulator'])file('ios/Pods/'+name+'/'+name+'.xcframework/'+arch+'/'+name+'.framework/Info.plist','Synthetic framework source inventory');} file('ios/Podfile.lock',lock);return run({root,file,pins,lock,provenance,capture:()=>podNoticeInputs(root).components}); });} +test('97 explicit RN and Expo own-package bindings resolve exact selected forms without component or vendor approval',()=>ownLicenseFixture(({root,pins,provenance,capture})=>{ + const items=capture();assert.equal(items.length,97);assert.equal(new Set(pins.map(value=>value.ownerNpmID)).size,28);assert.equal(pins.filter(value=>value.sourceKind==='path').length,91);assert.equal(provenance.components.length,0); + for(const pin of pins){const item=items.find(value=>value.id===pin.component),bytes=readFileSync(new URL('../'+pin.source,import.meta.url));assert.ok(item);assert.equal(item.nativeInputs['cocoapods:'+item.name].ownLicense.ownerNpmID,pin.ownerNpmID);assert.ok(item.notices.some(notice=>notice.source===pin.source&¬ice.sha256===pin.sha256&&Buffer.from(notice.body).equals(bytes)));assert.ok(!JSON.stringify(item).includes(root));} + const publicInput=unreviewedOSSNoticeInputs(items,provenance);assert.equal(publicInput.components.length,97);assert.equal(publicInput.complete,false);assert.equal(resolveNoticeCoverage(items,provenance).status,'INCOMPLETE'); + const vendored=items.filter(item=>item.vendors.length);assert.equal(vendored.length,6);for(const item of vendored)assert.ok(resolveNoticeCoverage([item],approval(item)).unresolved.some(value=>value.reason==='missing-vendored-notice')); + for(const component of ['cocoapods:ExpoRouter@57.0.28','cocoapods:ExpoUI@57.0.25','cocoapods:Yoga@0.0.0'])assert.ok(!provenance.knownLicenseSources.some(value=>value.component===component)); +},{batch:true})); + +test('the explicit batch rejects changed spec, root, form, package version and licence bytes; unknown siblings gain no binding',()=>ownLicenseFixture(({file,pins,lock,capture})=>{ + const selected=[pins.find(value=>value.ownerNpmID==='npm:react-native@0.86.3'&&value.sourceKind==='path'),pins.find(value=>value.ownerNpmID!=='npm:react-native@0.86.3'&&value.sourceKind==='path'),pins.find(value=>value.sourceKind==='podspec')]; + const mutate=(path,bytes)=>{const before=readFileSync(path);writeFileSync(path,bytes);assert.throws(capture);writeFileSync(path,before);}; + for(const pin of selected){const name=pin.component.slice('cocoapods:'.length,-pin.version.length-1); + for(const source of [pin.podspecSource,pin.source]){const path=file(source,readFileSync(new URL('../'+source,import.meta.url)));mutate(path,Buffer.concat([readFileSync(path),Buffer.from('\nChanged synthetic bytes')]));} + const pkgPath=file(pin.packageJSONSource,readFileSync(new URL('../'+pin.packageJSONSource,import.meta.url))),pkg=JSON.parse(readFileSync(pkgPath));mutate(pkgPath,JSON.stringify({...pkg,version:'999.0.0'})); + const alternate=pin.packagePath+'/alternate-pod-root';file(alternate+'/source.m','Synthetic adjacent source');for(const external of [{':path':'../'+alternate},{[pin.sourceKind==='path'?':podspec':':path']:'../'+(pin.sourceKind==='path'?pin.podspecSource:dirname(pin.podspecSource))}]){file('ios/Podfile.lock',{...lock,'EXTERNAL SOURCES':{...lock['EXTERNAL SOURCES'],[name]:external}});assert.throws(capture);}file('ios/Podfile.lock',lock); + } + const pin=selected[0],sibling='UnreviewedRNSibling';file('ios/Podfile.lock',{...lock,PODS:[...lock.PODS,sibling+' ('+pin.version+')'],'EXTERNAL SOURCES':{...lock['EXTERNAL SOURCES'],[sibling]:{':path':'../'+dirname(pin.podspecSource)}}});let item=capture().find(value=>value.name===sibling);assert.equal(item.nativeInputs['cocoapods:'+sibling].ownLicense,undefined); + const name=pin.component.slice('cocoapods:'.length,-pin.version.length-1);file('ios/Podfile.lock',{...lock,PODS:lock.PODS.map(value=>value===name+' ('+pin.version+')'?name+' (999.0.0)':value)});item=capture().find(value=>value.name===name);assert.equal(item.nativeInputs['cocoapods:'+name].ownLicense,undefined); +},{batch:true})); + test('three explicitly bound Pods retain exact own-package text without approving their vendor coverage',()=>ownLicenseFixture(({root,pins,provenance,capture})=>{ const items=capture();assert.equal(items.length,3);assert.equal(provenance.components.length,0);assert.equal(resolveNoticeCoverage(items,provenance).status,'INCOMPLETE'); for(const pin of pins){const item=items.find(value=>value.id===pin.component),bytes=readFileSync(new URL('../'+pin.source,import.meta.url));assert.equal(item.notices.length,1);assert.equal(item.notices[0].source,pin.source);assert.equal(item.notices[0].sha256,pin.sha256);assert.ok(Buffer.from(item.notices[0].body).equals(bytes));assert.equal(item.nativeInputs['cocoapods:'+item.name].ownLicense.ownerNpmID,pin.ownerNpmID);assert.ok(!JSON.stringify(item).includes(root));} From c8c03e4aec01dfec0785634c61455017f25498f4 Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 11:27:24 +0800 Subject: [PATCH 29/37] Report CRC-verified empty notice inputs without attribution --- docs/app-notices.md | 10 ++++++ scripts/app-notices.test.mjs | 7 ++-- scripts/capture-app-notices.mjs | 2 +- scripts/native-notice-inputs.mjs | 7 ++-- scripts/native-notice-inputs.test.mjs | 11 ++++++ scripts/notice-archive.py | 27 +++++++++++---- scripts/notice-archive.test.py | 49 +++++++++++++++++++++++++++ 7 files changed, 100 insertions(+), 13 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index 57491099..25340b41 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -100,6 +100,16 @@ still fail. The pinned [Kotlin producer](https://github.com/Kotlin/kotlinx.corou uses `DebugProbesKt.bin` as class bytes; this is a format distinction, not an archive-member exclusion or a notice-coverage approval. +Zero-byte notice members are reported separately as `emptyNotices` with their +logical source, zero length, empty SHA-256 and verified ZIP CRC. They never enter +the notice text list or resource-coverage map. Maven component `nativeInputs` +binds those records and the verified source archive hash; a changed or dropped +record invalidates its review. A component without actual text remains +`missing-notice-text`, even with a ledger entry. Other text and vendored sources +still require the normal explicit review. Final evidence retains empty records +separately, and an empty file cannot satisfy a required nonempty notice resource. +Nonempty invalid text and all existing archive/size limits remain strict. + The Gradle init script requires an explicit absolute `noticeMainProjectRoot` and compares canonical project directories before registering its task. Other included builds are skipped; a missing `:app` on the selected root still fails. diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs index b804cfdd..1df82a8d 100644 --- a/scripts/app-notices.test.mjs +++ b/scripts/app-notices.test.mjs @@ -151,7 +151,7 @@ test('actual verify CLI derives final components from map/native inputs and reje const aar=file('build/source.aar',''),staticJar=file('build/static.jar',''); execFileSync('python3',['-c',`import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:z.writestr('LICENSE',sys.argv[3]);z.writestr('jni/arm64-v8a/libqa.so',b'\\x7fELFcontrolled')\nwith zipfile.ZipFile(sys.argv[2],'w') as z:z.writestr('LICENSE',sys.argv[3])`,aar,staticJar,mit]); const gradle=file('build/runtime-inputs.json',{schema:1,configuration:'releaseRuntimeClasspath',components:[{group:'qa',name:'native',version:'1',artifacts:[{file:aar,sha256:digest(readFileSync(aar))}]},{group:'qa',name:'static',version:'1',artifacts:[{file:staticJar,sha256:digest(readFileSync(staticJar))}]}]}); - const empty={schema:1,components:[],knownLicenseSources:[],retainedAndroidResources:{}};file('scripts/app-notice-provenance.json',empty); + const empty={schema:1,components:[],knownLicenseSources:[],retainedAndroidResources:{'META-INF/NOTICE':digest('Required synthetic native notice\n')}};file('scripts/app-notice-provenance.json',empty); const paths=[appPath,modulePath,assetPath,join(root,'assets/notices/third_party_notices.json')]; const initial=captureResolvedInputs(root,'android',paths,gradle),approved={...empty,components:initial.noticeInputs.map(item=>review(item,'android'))};file('scripts/app-notice-provenance.json',approved); const coverage=resolveNoticeCoverage(initial.noticeInputs,approved,'android');file('assets/notices/third_party_notices.json',sourcePayload(coverage)); @@ -161,7 +161,7 @@ test('actual verify CLI derives final components from map/native inputs and reje assert.throws(()=>createNoticeSeal(join(root,'node_modules'), 'android',capture)); const sourceMap={version:3,sources:['index.ts','node_modules/qa/index.js','node_modules/assets/font.ttf','assets/notices/third_party_notices.json'],sourcesContent:[appCode,moduleCode,'',readFileSync(join(root,'assets/notices/third_party_notices.json'),'utf8')]}; const map=file('build/actual.map',sourceMap),bundle=file('build/index.android.bundle','controlled compiler output'),apk=join(root,'build/final.apk'); - const pack=(extra=false)=>execFileSync('python3',['-c',`import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:\n z.write(sys.argv[2],'assets/third_party_notices.json');z.write(sys.argv[3],'assets/index.android.bundle');z.writestr('lib/arm64-v8a/libqa.so',b'\\x7fELFcontrolled');z.writestr('assets/font.ttf','synthetic font bytes')\n if sys.argv[4]=='yes':z.writestr('assets/unknown-executable',b'\\xcf\\xfa\\xed\\xfecontrolled')`,apk,join(root,'assets/notices/third_party_notices.json'),bundle,extra?'yes':'no']); + const pack=(extra=false,emptyRetained=false)=>execFileSync('python3',['-c',`import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:\n z.write(sys.argv[2],'assets/third_party_notices.json');z.write(sys.argv[3],'assets/index.android.bundle');z.writestr('lib/arm64-v8a/libqa.so',b'\\x7fELFcontrolled');z.writestr('assets/font.ttf','synthetic font bytes');z.writestr('third_party_licenses.txt',b'');z.writestr('META-INF/NOTICE',b'' if sys.argv[5]=='yes' else sys.argv[6])\n if sys.argv[4]=='yes':z.writestr('assets/unknown-executable',b'\\xcf\\xfa\\xed\\xfecontrolled')`,apk,join(root,'assets/notices/third_party_notices.json'),bundle,extra?'yes':'no',emptyRetained?'yes':'no','Required synthetic native notice\n']); const verify=()=>spawnSync(process.execPath,[fileURLToPath(new URL('./capture-app-notices.mjs',import.meta.url)),'verify','android','--gradle-input',gradle,'--archive',apk,'--bundle',bundle,'--source-map',map],{cwd:root,encoding:'utf8',timeout:30000}); pack();assert.equal(verify().status,0); file('babel.config.cjs','module.exports={presets:["changed-preset"]};\n'); @@ -171,6 +171,9 @@ test('actual verify CLI derives final components from map/native inputs and reje file('babel.config.cjs',buildConfig);runGit('restore','--staged','--','babel.config.cjs'); assert.equal(verify().status,0,'Ignored generated build outputs do not make the tracked worktree dirty.'); const receipt=JSON.parse(readFileSync(join(root,'build/notices/android/final-apk.json')));assert.equal(receipt.inventory.independentFinalComponentInventory,false); + assert.deepEqual(receipt.emptyNotices,[{source:'third_party_licenses.txt',bytes:0,sha256:digest(''),crc32:'00000000'}]); + pack(false,true);assert.equal(verify().status,1,'An empty retained file cannot satisfy a required nonempty native notice resource.'); + pack();assert.equal(verify().status,0); assert.deepEqual(receipt.inventory.resolvedBuildOnly,[{component:'maven:qa:static@1',basis:'reinspected-gradle-artifacts-and-source'}]); const missing={...sourceMap,sources:sourceMap.sources.filter((_,i)=>i!==2),sourcesContent:sourceMap.sourcesContent.filter((_,i)=>i!==2)};file('build/actual.map',missing); assert.equal(verify().status,1,'Captured asset-only package cannot be supplied back as an observed final component.'); diff --git a/scripts/capture-app-notices.mjs b/scripts/capture-app-notices.mjs index 731f995a..f68d4a92 100644 --- a/scripts/capture-app-notices.mjs +++ b/scripts/capture-app-notices.mjs @@ -109,7 +109,7 @@ function finalNoticeProof(root,platform,seal,capture,payload,options){ const resolvedBuildOnly=[...resolved.values()].filter(value=>!observedComponents.includes(value.component)).sort((a,b)=>a.component.localeCompare(b.component)); const resources=Object.fromEntries(archive.notices.map(value=>[kind==='aab'?value.source.replace(/^base\/root\//,''):value.source,value.sha256])); assertFinalNoticeEvidence({manifest:seal,payload,observedComponents,resolvedBuildOnly,resources,retainedResources:platform==='android'?capture.ledger.retainedAndroidResources:{}}); - return {schema:1,scope:'final-artifact-verification',status:'READY',sourceCommit:seal.sourceCommit,platform,payloadSHA256:digest(payload),archiveSHA256:digest(readFileSync(options['--archive'])),bundleSHA256:archive.bundle.sha256,sourceMapSHA256:digest(readFileSync(options['--source-map'])),mapEvidence,native,inventory:{observedComponents,resolvedBuildOnly,independentFinalComponentInventory:false,postPackageResolvedInputReinspection:true,postBuildResolutionRegenerated:false},complete:true}; + return {schema:1,scope:'final-artifact-verification',status:'READY',sourceCommit:seal.sourceCommit,platform,payloadSHA256:digest(payload),archiveSHA256:digest(readFileSync(options['--archive'])),bundleSHA256:archive.bundle.sha256,sourceMapSHA256:digest(readFileSync(options['--source-map'])),mapEvidence,native,emptyNotices:archive.emptyNotices,inventory:{observedComponents,resolvedBuildOnly,independentFinalComponentInventory:false,postPackageResolvedInputReinspection:true,postBuildResolutionRegenerated:false},complete:true}; } function retainedCollectorFailure(path,sourceCommit,phase){ diff --git a/scripts/native-notice-inputs.mjs b/scripts/native-notice-inputs.mjs index a9152ba1..5be95f11 100644 --- a/scripts/native-notice-inputs.mjs +++ b/scripts/native-notice-inputs.mjs @@ -122,15 +122,16 @@ export function gradleNoticeInputs(root,inputFile){ scan.vendors=scan.vendors.map(scoped);scan.vendorNotices=Object.fromEntries(Object.entries(scan.vendorNotices).map(([key,sources])=>[scoped(key),sources])); components.push(...unionNoticeInputs([pkg],[{...pkg,...scan}]));continue; } - assert.ok(value.group&&value.name&&value.version&&value.artifacts.length);const notices=[],vendors=[]; + assert.ok(value.group&&value.name&&value.version&&value.artifacts.length);const notices=[],vendors=[],emptyNotices=[]; for(const artifact of value.artifacts){ const bytes=readFileSync(artifact.file);assert.equal(digest(bytes),artifact.sha256,'Resolved artifact changed.'); const inspected=noticeInputStep('gradle-archive-scan','maven',()=>inspectNoticeArchive(artifact.file),`maven:${value.group}:${value.name}@${value.version}`,undefined,artifact.sha256),prefix='maven:'+value.group+':'+value.name+':'+value.version+'/'+basename(artifact.file)+'!'; notices.push(...inspected.notices.map(item=>({...item,source:prefix+item.source}))); + emptyNotices.push(...inspected.emptyNotices.map(item=>({...item,source:prefix+item.source,archiveSHA256:artifact.sha256}))); nativeFiles.push(...inspected.nativeFiles.map(item=>({...item,component:`maven:${value.group}:${value.name}@${value.version}`}))); - for(const item of inspected.notices)if(/(^|\/)(vendor|third[-_]?party)(\/|$)/i.test(item.source))vendors.push(item.source); + for(const item of [...inspected.notices,...inspected.emptyNotices])if(/(^|\/)(vendor|third[-_]?party)(\/|$)/i.test(item.source))vendors.push(item.source); } - components.push(nativeNoticeInput({id:`maven:${value.group}:${value.name}@${value.version}`,name:value.group+':'+value.name,version:value.version,ecosystem:'maven',notices,vendors})); + components.push(nativeNoticeInput({id:`maven:${value.group}:${value.name}@${value.version}`,name:value.group+':'+value.name,version:value.version,ecosystem:'maven',notices,vendors,nativeInputs:emptyNotices.length?{emptyNotices:emptyNotices.sort((a,b)=>a.source.localeCompare(b.source))}:{}})); } return {components,nativeFiles,unresolved:[],inputHashes:{gradle:digest(readFileSync(inputFile))}}; } diff --git a/scripts/native-notice-inputs.test.mjs b/scripts/native-notice-inputs.test.mjs index b5469540..81f259c1 100644 --- a/scripts/native-notice-inputs.test.mjs +++ b/scripts/native-notice-inputs.test.mjs @@ -101,6 +101,17 @@ test('actual AAR and nested JAR LICENSE plus NOTICE text are retained, with arti input.components[0].artifacts[0].sha256='0'.repeat(64);file('private-gradle-input.json',input);assert.throws(()=>gradleNoticeInputs(root,path)); })); +test('real empty archive notices remain fingerprinted metadata, missing text and vendor review are never satisfied by them',()=>fixture(({root,file})=>{ + const aar=join(root,'empty.aar'),input={schema:1,configuration:'releaseRuntimeClasspath',components:[{group:'qa',name:'empty',version:'1',artifacts:[]}]},path=join(root,'runtime.json'); + const capture=(text=false,vendor=false)=>{execFileSync('python3',['-c',`import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:\n z.writestr('third_party/vendor/LICENSE' if sys.argv[3]=='yes' else 'third_party_licenses.txt',b'')\n if sys.argv[2]=='yes':z.writestr('LICENSE','Actual synthetic notice\\n')`,aar,text?'yes':'no',vendor?'yes':'no']);input.components[0].artifacts=[{file:aar,sha256:digest(readFileSync(aar))}];file('runtime.json',input);return gradleNoticeInputs(root,path).components[0];}; + const empty=capture(),record={source:'maven:qa:empty:1/empty.aar!third_party_licenses.txt',bytes:0,sha256:digest(''),crc32:'00000000',archiveSHA256:input.components[0].artifacts[0].sha256};assert.deepEqual(empty.notices,[]);assert.deepEqual(empty.nativeInputs,{emptyNotices:[record]});assert.ok(!JSON.stringify(empty).includes(root)); + assert.equal(resolveNoticeCoverage([empty],approval(empty,'android'),'android').unresolved[0].reason,'missing-notice-text');assert.equal(unreviewedOSSNoticeInputs([empty],{}).components.length,0); + const actual=capture(true),reviewed=approval(actual,'android');assert.equal(actual.notices.length,1);assert.equal(resolveNoticeCoverage([actual],{schema:1,components:[]},'android').unresolved[0].reason,'unreviewed-component');assert.equal(resolveNoticeCoverage([actual],reviewed,'android').status,'READY'); + for(const field of ['source','bytes','sha256','crc32','archiveSHA256']){const changed=structuredClone(actual);changed.nativeInputs.emptyNotices[0][field]=field==='bytes'?1:'changed';assert.equal(resolveNoticeCoverage([changed],reviewed,'android').unresolved[0].reason,'native-source-digest-mismatch');} + for(const inputs of [{},{emptyNotices:[]}]){const changed={...actual,nativeInputs:inputs};assert.equal(resolveNoticeCoverage([changed],reviewed,'android').unresolved[0].reason,'native-source-digest-mismatch');} + const vendored=capture(true,true);assert.deepEqual(vendored.vendors,['third_party/vendor/LICENSE']);assert.equal(resolveNoticeCoverage([vendored],approval(vendored,'android'),'android').unresolved[0].reason,'missing-vendored-notice'); +})); + test('archive reader rejects duplicate/traversal input, and reads the exact final JSON resource without extraction',()=>fixture(({root})=>{ const path=join(root,'fixture.apk'),payload=JSON.stringify([{id:'qa',title:'QA',body:mit}])+'\n'; execFileSync('python3',['-c',`import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:z.writestr('assets/third_party_notices.json',sys.argv[2])`,path,payload]); diff --git a/scripts/notice-archive.py b/scripts/notice-archive.py index cdd6c1ee..43b7a2fb 100644 --- a/scripts/notice-archive.py +++ b/scripts/notice-archive.py @@ -1,5 +1,6 @@ """Read-only, bounded SDK/archive notice input. Never extracts files.""" import base64 +import copy import hashlib import io import json @@ -7,6 +8,7 @@ import struct import sys import zipfile +import zlib from pathlib import Path, PurePosixPath MAX_TEXT = 1024 * 1024 @@ -150,7 +152,7 @@ def is_macho(archive, entry): def inspect_archive(path, payload_path=None, bundle_path=None): - result = {"notices": [], "nativeFiles": [], "payload": None, "bundle": None} + result = {"notices": [], "emptyNotices": [], "nativeFiles": [], "payload": None, "bundle": None} total = 0 def inspect(source, prefix="", depth=0): @@ -180,16 +182,27 @@ def inspect(source, prefix="", depth=0): raise ValueError("Empty or oversized application bundle") result["bundle"] = {"sha256": hashlib.sha256(archive.read(entry)).hexdigest(), "bytes": entry.file_size} if not (payload_path and not prefix and name == payload_path) and any(re.fullmatch(r"(licen[sc]es?|notices?|copying|copyright|third[._-]?party[._-]?(licen[sc]es?|notices?))([._-].*)?", part, re.I) for part in PurePosixPath(name).parts): - if not 0 < entry.file_size <= MAX_TEXT: + if entry.file_size > MAX_TEXT: raise ValueError("Empty or oversized notice") total += entry.file_size if total > MAX_TOTAL_TEXT: raise ValueError("Too much notice text") - raw = archive.read(entry) - body = raw.decode("utf-8") - if not body.strip() or "\0" in body: - raise ValueError("Unreadable notice") - result["notices"].append({"source": logical, "body": body, "sha256": hashlib.sha256(raw).hexdigest()}) + if entry.file_size == 0: + # ZipExtFile truncates at the declared size. A one-byte + # probe must also prove that the actual member is empty. + probe = copy.copy(entry) + probe.file_size = 1 + with archive.open(probe) as stream: + raw = stream.read(1) + if raw or stream.read(1) or (zlib.crc32(raw) & 0xffffffff) != entry.CRC: + raise zipfile.BadZipFile("Invalid empty notice input") + result["emptyNotices"].append({"source": logical, "bytes": 0, "sha256": hashlib.sha256(raw).hexdigest(), "crc32": f"{entry.CRC:08x}"}) + else: + raw = archive.read(entry) + body = raw.decode("utf-8") + if not body.strip() or "\0" in body: + raise ValueError("Unreadable notice") + result["notices"].append({"source": logical, "body": body, "sha256": hashlib.sha256(raw).hexdigest()}) if name.endswith(".so") or is_macho(archive, entry): result["nativeFiles"].append({"path": logical, "sha256": native_digest(archive, entry)}) if depth == 0 and name.endswith(".jar"): diff --git a/scripts/notice-archive.test.py b/scripts/notice-archive.test.py index 49377f6a..1338191e 100644 --- a/scripts/notice-archive.test.py +++ b/scripts/notice-archive.test.py @@ -20,6 +20,55 @@ def jvm_fixture(extra=b"", slots=0, major=52): class NativeArchiveTests(unittest.TestCase): + def test_empty_notices_are_crc_verified_metadata_and_never_text(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "empty.aar" + for compression in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED): + nested = io.BytesIO() + with zipfile.ZipFile(nested, "w", compression=compression) as archive: + archive.writestr("META-INF/NOTICE", b"") + with zipfile.ZipFile(path, "w", compression=compression) as archive: + archive.writestr("third_party_licenses.txt", b"") + archive.writestr("LICENSE", b"Actual synthetic notice\n") + archive.writestr("classes.jar", nested.getvalue()) + result = reader.inspect_archive(path) + self.assertEqual(result["notices"], [{"source": "LICENSE", "body": "Actual synthetic notice\n", "sha256": hashlib.sha256(b"Actual synthetic notice\n").hexdigest()}]) + self.assertEqual(result["emptyNotices"], [{"source": source, "bytes": 0, "sha256": hashlib.sha256(b"").hexdigest(), "crc32": "00000000"} for source in ("third_party_licenses.txt", "classes.jar!META-INF/NOTICE")]) + + def test_empty_notice_bad_crc_and_nonempty_member_forged_as_zero_fail(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "corrupt.zip" + for compression in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED): + for forged in (False, True): + with self.subTest(compression=compression, forged=forged): + with zipfile.ZipFile(path, "w", compression=compression) as archive: + archive.writestr("NOTICE", b"nonempty" if forged else b"") + data = bytearray(path.read_bytes()) + central = data.index(b"PK\x01\x02") + struct.pack_into(" Date: Sun, 15 Nov 2026 11:36:46 +0800 Subject: [PATCH 30/37] Keep empty JSON licence descriptors as typed non-text evidence --- docs/app-notices.md | 17 +++++---- scripts/app-notices.test.mjs | 7 ++-- scripts/capture-app-notices.mjs | 2 +- scripts/native-notice-inputs.mjs | 8 ++--- scripts/native-notice-inputs.test.mjs | 10 +++--- scripts/notice-archive.py | 36 +++++++++++++------ scripts/notice-archive.test.py | 52 +++++++++++++++++++++++---- 7 files changed, 96 insertions(+), 36 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index 25340b41..c259e85a 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -100,14 +100,17 @@ still fail. The pinned [Kotlin producer](https://github.com/Kotlin/kotlinx.corou uses `DebugProbesKt.bin` as class bytes; this is a format distinction, not an archive-member exclusion or a notice-coverage approval. -Zero-byte notice members are reported separately as `emptyNotices` with their -logical source, zero length, empty SHA-256 and verified ZIP CRC. They never enter -the notice text list or resource-coverage map. Maven component `nativeInputs` -binds those records and the verified source archive hash; a changed or dropped -record invalidates its review. A component without actual text remains +Non-text notice inputs use one `nonTextNotices` record shape: closed kind, +logical source, declared bytes, exact SHA-256 and verified ZIP CRC, without body +text. Kinds are `empty-bytes` and `empty-json-descriptor`; the latter recognizes +only matched `.json` inputs whose valid UTF-8 JSON is an empty object or array. +Other JSON and non-JSON candidates retain their existing behavior. Neither kind +enters the text list or resource-coverage map. Maven component `nativeInputs` +binds these records in `noticeArchives` with the verified source archive hash; +a changed or dropped record invalidates its review. A component without text remains `missing-notice-text`, even with a ledger entry. Other text and vendored sources -still require the normal explicit review. Final evidence retains empty records -separately, and an empty file cannot satisfy a required nonempty notice resource. +still require the normal explicit review. Final evidence retains non-text records +separately; neither an empty file nor descriptor satisfies a required text resource. Nonempty invalid text and all existing archive/size limits remain strict. The Gradle init script requires an explicit absolute `noticeMainProjectRoot` and diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs index 1df82a8d..a5242ec5 100644 --- a/scripts/app-notices.test.mjs +++ b/scripts/app-notices.test.mjs @@ -151,7 +151,7 @@ test('actual verify CLI derives final components from map/native inputs and reje const aar=file('build/source.aar',''),staticJar=file('build/static.jar',''); execFileSync('python3',['-c',`import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:z.writestr('LICENSE',sys.argv[3]);z.writestr('jni/arm64-v8a/libqa.so',b'\\x7fELFcontrolled')\nwith zipfile.ZipFile(sys.argv[2],'w') as z:z.writestr('LICENSE',sys.argv[3])`,aar,staticJar,mit]); const gradle=file('build/runtime-inputs.json',{schema:1,configuration:'releaseRuntimeClasspath',components:[{group:'qa',name:'native',version:'1',artifacts:[{file:aar,sha256:digest(readFileSync(aar))}]},{group:'qa',name:'static',version:'1',artifacts:[{file:staticJar,sha256:digest(readFileSync(staticJar))}]}]}); - const empty={schema:1,components:[],knownLicenseSources:[],retainedAndroidResources:{'META-INF/NOTICE':digest('Required synthetic native notice\n')}};file('scripts/app-notice-provenance.json',empty); + const empty={schema:1,components:[],knownLicenseSources:[],retainedAndroidResources:{'META-INF/NOTICE.json':digest('{"notice":"Required synthetic native notice"}\n')}};file('scripts/app-notice-provenance.json',empty); const paths=[appPath,modulePath,assetPath,join(root,'assets/notices/third_party_notices.json')]; const initial=captureResolvedInputs(root,'android',paths,gradle),approved={...empty,components:initial.noticeInputs.map(item=>review(item,'android'))};file('scripts/app-notice-provenance.json',approved); const coverage=resolveNoticeCoverage(initial.noticeInputs,approved,'android');file('assets/notices/third_party_notices.json',sourcePayload(coverage)); @@ -161,7 +161,7 @@ test('actual verify CLI derives final components from map/native inputs and reje assert.throws(()=>createNoticeSeal(join(root,'node_modules'), 'android',capture)); const sourceMap={version:3,sources:['index.ts','node_modules/qa/index.js','node_modules/assets/font.ttf','assets/notices/third_party_notices.json'],sourcesContent:[appCode,moduleCode,'',readFileSync(join(root,'assets/notices/third_party_notices.json'),'utf8')]}; const map=file('build/actual.map',sourceMap),bundle=file('build/index.android.bundle','controlled compiler output'),apk=join(root,'build/final.apk'); - const pack=(extra=false,emptyRetained=false)=>execFileSync('python3',['-c',`import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:\n z.write(sys.argv[2],'assets/third_party_notices.json');z.write(sys.argv[3],'assets/index.android.bundle');z.writestr('lib/arm64-v8a/libqa.so',b'\\x7fELFcontrolled');z.writestr('assets/font.ttf','synthetic font bytes');z.writestr('third_party_licenses.txt',b'');z.writestr('META-INF/NOTICE',b'' if sys.argv[5]=='yes' else sys.argv[6])\n if sys.argv[4]=='yes':z.writestr('assets/unknown-executable',b'\\xcf\\xfa\\xed\\xfecontrolled')`,apk,join(root,'assets/notices/third_party_notices.json'),bundle,extra?'yes':'no',emptyRetained?'yes':'no','Required synthetic native notice\n']); + const pack=(extra=false,emptyRetained=false)=>execFileSync('python3',['-c',`import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:\n z.write(sys.argv[2],'assets/third_party_notices.json');z.write(sys.argv[3],'assets/index.android.bundle');z.writestr('lib/arm64-v8a/libqa.so',b'\\x7fELFcontrolled');z.writestr('assets/font.ttf','synthetic font bytes');z.writestr('third_party_licenses.txt',b'');z.writestr('third_party_licenses.json',sys.argv[7]);z.writestr('META-INF/NOTICE.json',b'' if sys.argv[5]=='yes' else sys.argv[7] if sys.argv[5]=='json' else sys.argv[6])\n if sys.argv[4]=='yes':z.writestr('assets/unknown-executable',b'\\xcf\\xfa\\xed\\xfecontrolled')`,apk,join(root,'assets/notices/third_party_notices.json'),bundle,extra?'yes':'no',emptyRetained===true?'yes':emptyRetained==='json'?'json':'no','{"notice":"Required synthetic native notice"}\n','{}\n']); const verify=()=>spawnSync(process.execPath,[fileURLToPath(new URL('./capture-app-notices.mjs',import.meta.url)),'verify','android','--gradle-input',gradle,'--archive',apk,'--bundle',bundle,'--source-map',map],{cwd:root,encoding:'utf8',timeout:30000}); pack();assert.equal(verify().status,0); file('babel.config.cjs','module.exports={presets:["changed-preset"]};\n'); @@ -171,8 +171,9 @@ test('actual verify CLI derives final components from map/native inputs and reje file('babel.config.cjs',buildConfig);runGit('restore','--staged','--','babel.config.cjs'); assert.equal(verify().status,0,'Ignored generated build outputs do not make the tracked worktree dirty.'); const receipt=JSON.parse(readFileSync(join(root,'build/notices/android/final-apk.json')));assert.equal(receipt.inventory.independentFinalComponentInventory,false); - assert.deepEqual(receipt.emptyNotices,[{source:'third_party_licenses.txt',bytes:0,sha256:digest(''),crc32:'00000000'}]); + assert.deepEqual(receipt.nonTextNotices,[{kind:'empty-bytes',source:'third_party_licenses.txt',bytes:0,sha256:digest(''),crc32:'00000000'},{kind:'empty-json-descriptor',source:'third_party_licenses.json',bytes:3,sha256:digest('{}\n'),crc32:'dda1b006'}]); pack(false,true);assert.equal(verify().status,1,'An empty retained file cannot satisfy a required nonempty native notice resource.'); + pack(false,'json');assert.equal(verify().status,1,'An empty JSON descriptor cannot satisfy a required nonempty native notice resource.'); pack();assert.equal(verify().status,0); assert.deepEqual(receipt.inventory.resolvedBuildOnly,[{component:'maven:qa:static@1',basis:'reinspected-gradle-artifacts-and-source'}]); const missing={...sourceMap,sources:sourceMap.sources.filter((_,i)=>i!==2),sourcesContent:sourceMap.sourcesContent.filter((_,i)=>i!==2)};file('build/actual.map',missing); diff --git a/scripts/capture-app-notices.mjs b/scripts/capture-app-notices.mjs index f68d4a92..e7335054 100644 --- a/scripts/capture-app-notices.mjs +++ b/scripts/capture-app-notices.mjs @@ -109,7 +109,7 @@ function finalNoticeProof(root,platform,seal,capture,payload,options){ const resolvedBuildOnly=[...resolved.values()].filter(value=>!observedComponents.includes(value.component)).sort((a,b)=>a.component.localeCompare(b.component)); const resources=Object.fromEntries(archive.notices.map(value=>[kind==='aab'?value.source.replace(/^base\/root\//,''):value.source,value.sha256])); assertFinalNoticeEvidence({manifest:seal,payload,observedComponents,resolvedBuildOnly,resources,retainedResources:platform==='android'?capture.ledger.retainedAndroidResources:{}}); - return {schema:1,scope:'final-artifact-verification',status:'READY',sourceCommit:seal.sourceCommit,platform,payloadSHA256:digest(payload),archiveSHA256:digest(readFileSync(options['--archive'])),bundleSHA256:archive.bundle.sha256,sourceMapSHA256:digest(readFileSync(options['--source-map'])),mapEvidence,native,emptyNotices:archive.emptyNotices,inventory:{observedComponents,resolvedBuildOnly,independentFinalComponentInventory:false,postPackageResolvedInputReinspection:true,postBuildResolutionRegenerated:false},complete:true}; + return {schema:1,scope:'final-artifact-verification',status:'READY',sourceCommit:seal.sourceCommit,platform,payloadSHA256:digest(payload),archiveSHA256:digest(readFileSync(options['--archive'])),bundleSHA256:archive.bundle.sha256,sourceMapSHA256:digest(readFileSync(options['--source-map'])),mapEvidence,native,nonTextNotices:archive.nonTextNotices,inventory:{observedComponents,resolvedBuildOnly,independentFinalComponentInventory:false,postPackageResolvedInputReinspection:true,postBuildResolutionRegenerated:false},complete:true}; } function retainedCollectorFailure(path,sourceCommit,phase){ diff --git a/scripts/native-notice-inputs.mjs b/scripts/native-notice-inputs.mjs index 5be95f11..2a9e1d0e 100644 --- a/scripts/native-notice-inputs.mjs +++ b/scripts/native-notice-inputs.mjs @@ -122,16 +122,16 @@ export function gradleNoticeInputs(root,inputFile){ scan.vendors=scan.vendors.map(scoped);scan.vendorNotices=Object.fromEntries(Object.entries(scan.vendorNotices).map(([key,sources])=>[scoped(key),sources])); components.push(...unionNoticeInputs([pkg],[{...pkg,...scan}]));continue; } - assert.ok(value.group&&value.name&&value.version&&value.artifacts.length);const notices=[],vendors=[],emptyNotices=[]; + assert.ok(value.group&&value.name&&value.version&&value.artifacts.length);const notices=[],vendors=[],noticeArchives=[]; for(const artifact of value.artifacts){ const bytes=readFileSync(artifact.file);assert.equal(digest(bytes),artifact.sha256,'Resolved artifact changed.'); const inspected=noticeInputStep('gradle-archive-scan','maven',()=>inspectNoticeArchive(artifact.file),`maven:${value.group}:${value.name}@${value.version}`,undefined,artifact.sha256),prefix='maven:'+value.group+':'+value.name+':'+value.version+'/'+basename(artifact.file)+'!'; notices.push(...inspected.notices.map(item=>({...item,source:prefix+item.source}))); - emptyNotices.push(...inspected.emptyNotices.map(item=>({...item,source:prefix+item.source,archiveSHA256:artifact.sha256}))); + if(inspected.nonTextNotices.length)noticeArchives.push({archiveSHA256:artifact.sha256,nonTextNotices:inspected.nonTextNotices.map(item=>({...item,source:prefix+item.source}))}); nativeFiles.push(...inspected.nativeFiles.map(item=>({...item,component:`maven:${value.group}:${value.name}@${value.version}`}))); - for(const item of [...inspected.notices,...inspected.emptyNotices])if(/(^|\/)(vendor|third[-_]?party)(\/|$)/i.test(item.source))vendors.push(item.source); + for(const item of [...inspected.notices,...inspected.nonTextNotices])if(/(^|\/)(vendor|third[-_]?party)(\/|$)/i.test(item.source))vendors.push(item.source); } - components.push(nativeNoticeInput({id:`maven:${value.group}:${value.name}@${value.version}`,name:value.group+':'+value.name,version:value.version,ecosystem:'maven',notices,vendors,nativeInputs:emptyNotices.length?{emptyNotices:emptyNotices.sort((a,b)=>a.source.localeCompare(b.source))}:{}})); + components.push(nativeNoticeInput({id:`maven:${value.group}:${value.name}@${value.version}`,name:value.group+':'+value.name,version:value.version,ecosystem:'maven',notices,vendors,nativeInputs:noticeArchives.length?{noticeArchives:noticeArchives.sort((a,b)=>a.archiveSHA256.localeCompare(b.archiveSHA256)||a.nonTextNotices[0].source.localeCompare(b.nonTextNotices[0].source))}:{}})); } return {components,nativeFiles,unresolved:[],inputHashes:{gradle:digest(readFileSync(inputFile))}}; } diff --git a/scripts/native-notice-inputs.test.mjs b/scripts/native-notice-inputs.test.mjs index 81f259c1..64963842 100644 --- a/scripts/native-notice-inputs.test.mjs +++ b/scripts/native-notice-inputs.test.mjs @@ -103,12 +103,14 @@ test('actual AAR and nested JAR LICENSE plus NOTICE text are retained, with arti test('real empty archive notices remain fingerprinted metadata, missing text and vendor review are never satisfied by them',()=>fixture(({root,file})=>{ const aar=join(root,'empty.aar'),input={schema:1,configuration:'releaseRuntimeClasspath',components:[{group:'qa',name:'empty',version:'1',artifacts:[]}]},path=join(root,'runtime.json'); - const capture=(text=false,vendor=false)=>{execFileSync('python3',['-c',`import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:\n z.writestr('third_party/vendor/LICENSE' if sys.argv[3]=='yes' else 'third_party_licenses.txt',b'')\n if sys.argv[2]=='yes':z.writestr('LICENSE','Actual synthetic notice\\n')`,aar,text?'yes':'no',vendor?'yes':'no']);input.components[0].artifacts=[{file:aar,sha256:digest(readFileSync(aar))}];file('runtime.json',input);return gradleNoticeInputs(root,path).components[0];}; - const empty=capture(),record={source:'maven:qa:empty:1/empty.aar!third_party_licenses.txt',bytes:0,sha256:digest(''),crc32:'00000000',archiveSHA256:input.components[0].artifacts[0].sha256};assert.deepEqual(empty.notices,[]);assert.deepEqual(empty.nativeInputs,{emptyNotices:[record]});assert.ok(!JSON.stringify(empty).includes(root)); + const capture=(text=false,vendor=false,descriptor=false)=>{execFileSync('python3',['-c',`import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:\n z.writestr('third_party/vendor/LICENSE' if sys.argv[3]=='yes' else 'third_party_licenses.json' if sys.argv[4]=='yes' else 'third_party_licenses.txt',b'{}' if sys.argv[4]=='yes' else b'')\n if sys.argv[2]=='yes':z.writestr('LICENSE','Actual synthetic notice\\n')`,aar,text?'yes':'no',vendor?'yes':'no',descriptor?'yes':'no']);input.components[0].artifacts=[{file:aar,sha256:digest(readFileSync(aar))}];file('runtime.json',input);return gradleNoticeInputs(root,path).components[0];}; + const empty=capture(),record={kind:'empty-bytes',source:'maven:qa:empty:1/empty.aar!third_party_licenses.txt',bytes:0,sha256:digest(''),crc32:'00000000'};assert.deepEqual(empty.notices,[]);assert.deepEqual(empty.nativeInputs,{noticeArchives:[{archiveSHA256:input.components[0].artifacts[0].sha256,nonTextNotices:[record]}]});assert.ok(!JSON.stringify(empty).includes(root)); assert.equal(resolveNoticeCoverage([empty],approval(empty,'android'),'android').unresolved[0].reason,'missing-notice-text');assert.equal(unreviewedOSSNoticeInputs([empty],{}).components.length,0); + const descriptor=capture(false,false,true);assert.deepEqual(descriptor.notices,[]);assert.deepEqual(descriptor.nativeInputs.noticeArchives[0].nonTextNotices[0],{kind:'empty-json-descriptor',source:'maven:qa:empty:1/empty.aar!third_party_licenses.json',bytes:2,sha256:digest('{}'),crc32:'a3a6bf43'});assert.equal(resolveNoticeCoverage([descriptor],approval(descriptor,'android'),'android').unresolved[0].reason,'missing-notice-text'); const actual=capture(true),reviewed=approval(actual,'android');assert.equal(actual.notices.length,1);assert.equal(resolveNoticeCoverage([actual],{schema:1,components:[]},'android').unresolved[0].reason,'unreviewed-component');assert.equal(resolveNoticeCoverage([actual],reviewed,'android').status,'READY'); - for(const field of ['source','bytes','sha256','crc32','archiveSHA256']){const changed=structuredClone(actual);changed.nativeInputs.emptyNotices[0][field]=field==='bytes'?1:'changed';assert.equal(resolveNoticeCoverage([changed],reviewed,'android').unresolved[0].reason,'native-source-digest-mismatch');} - for(const inputs of [{},{emptyNotices:[]}]){const changed={...actual,nativeInputs:inputs};assert.equal(resolveNoticeCoverage([changed],reviewed,'android').unresolved[0].reason,'native-source-digest-mismatch');} + for(const field of ['kind','source','bytes','sha256','crc32']){const changed=structuredClone(actual);changed.nativeInputs.noticeArchives[0].nonTextNotices[0][field]=field==='bytes'?1:'changed';assert.equal(resolveNoticeCoverage([changed],reviewed,'android').unresolved[0].reason,'native-source-digest-mismatch');} + const archiveDrift=structuredClone(actual);archiveDrift.nativeInputs.noticeArchives[0].archiveSHA256='changed';assert.equal(resolveNoticeCoverage([archiveDrift],reviewed,'android').unresolved[0].reason,'native-source-digest-mismatch'); + for(const inputs of [{},{noticeArchives:[]},{noticeArchives:[{...actual.nativeInputs.noticeArchives[0],nonTextNotices:[]}]}]){const changed={...actual,nativeInputs:inputs};assert.equal(resolveNoticeCoverage([changed],reviewed,'android').unresolved[0].reason,'native-source-digest-mismatch');} const vendored=capture(true,true);assert.deepEqual(vendored.vendors,['third_party/vendor/LICENSE']);assert.equal(resolveNoticeCoverage([vendored],approval(vendored,'android'),'android').unresolved[0].reason,'missing-vendored-notice'); })); diff --git a/scripts/notice-archive.py b/scripts/notice-archive.py index 43b7a2fb..0ac55a9c 100644 --- a/scripts/notice-archive.py +++ b/scripts/notice-archive.py @@ -151,8 +151,24 @@ def is_macho(archive, entry): raise ValueError("Malformed or unsupported universal native input") +def non_text_notice(archive, entry, source, kind, expected): + if (kind not in ("empty-bytes", "empty-json-descriptor") + or (kind == "empty-bytes") != (entry.file_size == 0) + or len(expected) != entry.file_size or not 0 <= entry.file_size <= MAX_TEXT): + raise ValueError("Invalid non-text notice input") + # ZipExtFile truncates at the declared size. Probe one extra byte on a + # copy so a forged length cannot hide text after an empty descriptor. + probe = copy.copy(entry) + probe.file_size += 1 + with archive.open(probe) as stream: + raw = stream.read(entry.file_size) + if raw != expected or stream.read(1) or (zlib.crc32(raw) & 0xffffffff) != entry.CRC: + raise zipfile.BadZipFile("Invalid non-text notice input") + return {"kind": kind, "source": source, "bytes": entry.file_size, "sha256": hashlib.sha256(raw).hexdigest(), "crc32": f"{entry.CRC:08x}"} + + def inspect_archive(path, payload_path=None, bundle_path=None): - result = {"notices": [], "emptyNotices": [], "nativeFiles": [], "payload": None, "bundle": None} + result = {"notices": [], "nonTextNotices": [], "nativeFiles": [], "payload": None, "bundle": None} total = 0 def inspect(source, prefix="", depth=0): @@ -188,21 +204,19 @@ def inspect(source, prefix="", depth=0): if total > MAX_TOTAL_TEXT: raise ValueError("Too much notice text") if entry.file_size == 0: - # ZipExtFile truncates at the declared size. A one-byte - # probe must also prove that the actual member is empty. - probe = copy.copy(entry) - probe.file_size = 1 - with archive.open(probe) as stream: - raw = stream.read(1) - if raw or stream.read(1) or (zlib.crc32(raw) & 0xffffffff) != entry.CRC: - raise zipfile.BadZipFile("Invalid empty notice input") - result["emptyNotices"].append({"source": logical, "bytes": 0, "sha256": hashlib.sha256(raw).hexdigest(), "crc32": f"{entry.CRC:08x}"}) + result["nonTextNotices"].append(non_text_notice(archive, entry, logical, "empty-bytes", b"")) else: raw = archive.read(entry) body = raw.decode("utf-8") if not body.strip() or "\0" in body: raise ValueError("Unreadable notice") - result["notices"].append({"source": logical, "body": body, "sha256": hashlib.sha256(raw).hexdigest()}) + # This is exactly the JSON empty-container grammar; + # other candidates are not parsed or reclassified. + descriptor = name.lower().endswith(".json") and re.fullmatch(r"[ \t\r\n]*(?:\{[ \t\r\n]*\}|\[[ \t\r\n]*\])[ \t\r\n]*", body) + if descriptor: + result["nonTextNotices"].append(non_text_notice(archive, entry, logical, "empty-json-descriptor", raw)) + else: + result["notices"].append({"source": logical, "body": body, "sha256": hashlib.sha256(raw).hexdigest()}) if name.endswith(".so") or is_macho(archive, entry): result["nativeFiles"].append({"path": logical, "sha256": native_digest(archive, entry)}) if depth == 0 and name.endswith(".jar"): diff --git a/scripts/notice-archive.test.py b/scripts/notice-archive.test.py index 1338191e..fffeb497 100644 --- a/scripts/notice-archive.test.py +++ b/scripts/notice-archive.test.py @@ -8,6 +8,7 @@ from types import SimpleNamespace import unittest import zipfile +import zlib spec = importlib.util.spec_from_file_location("notice_archive", Path(__file__).with_name("notice-archive.py")) reader = importlib.util.module_from_spec(spec) @@ -33,7 +34,7 @@ def test_empty_notices_are_crc_verified_metadata_and_never_text(self): archive.writestr("classes.jar", nested.getvalue()) result = reader.inspect_archive(path) self.assertEqual(result["notices"], [{"source": "LICENSE", "body": "Actual synthetic notice\n", "sha256": hashlib.sha256(b"Actual synthetic notice\n").hexdigest()}]) - self.assertEqual(result["emptyNotices"], [{"source": source, "bytes": 0, "sha256": hashlib.sha256(b"").hexdigest(), "crc32": "00000000"} for source in ("third_party_licenses.txt", "classes.jar!META-INF/NOTICE")]) + self.assertEqual(result["nonTextNotices"], [{"kind": "empty-bytes", "source": source, "bytes": 0, "sha256": hashlib.sha256(b"").hexdigest(), "crc32": "00000000"} for source in ("third_party_licenses.txt", "classes.jar!META-INF/NOTICE")]) def test_empty_notice_bad_crc_and_nonempty_member_forged_as_zero_fail(self): with tempfile.TemporaryDirectory() as directory: @@ -54,14 +55,53 @@ def test_empty_notice_bad_crc_and_nonempty_member_forged_as_zero_fail(self): with self.assertRaises(zipfile.BadZipFile): reader.inspect_archive(path) + def test_only_matched_json_empty_containers_are_non_text_descriptors(self): + cases = {"LICENSE.json": b"{}\n", "sub/NOTICE.JSON": b" [\n] ", + "LICENSE.txt": b"{}", "NOTICE.json": b'{"notice":"Actual text"}', + "COPYING.json": b'["Actual text"]', "copyright.json": b"not JSON", + "NOTICE.null.json": b"null", "NOTICE.string.json": b'""', + "NOTICE.deep.json": b"[" * 1100 + b"0" + b"]" * 1100} + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "descriptors.zip" + with zipfile.ZipFile(path, "w") as archive: + for name, body in cases.items(): + archive.writestr(name, body) + result = reader.inspect_archive(path) + self.assertEqual(result["nonTextNotices"], [{"kind": "empty-json-descriptor", "source": name, "bytes": len(cases[name]), "sha256": hashlib.sha256(cases[name]).hexdigest(), "crc32": f"{zlib.crc32(cases[name]):08x}"} for name in ("LICENSE.json", "sub/NOTICE.JSON")]) + self.assertEqual([(x["source"], x["body"]) for x in result["notices"]], [(name, body.decode()) for name, body in list(cases.items())[2:]]) + for kind, size, data in [("unknown", 0, b""), ("empty-json-descriptor", 0, b""), ("empty-bytes", 1, b"a")]: + with self.assertRaises(ValueError): + reader.non_text_notice(None, SimpleNamespace(file_size=size), "NOTICE", kind, data) + + def test_json_descriptor_crc_and_forged_trailing_payload_are_rejected(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "descriptor.zip" + for compression in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED): + for forged in (False, True): + with self.subTest(compression=compression, forged=forged): + with zipfile.ZipFile(path, "w", compression=compression) as archive: + archive.writestr("NOTICE.json", b"{}\nhidden" if forged else b"{}\n") + data = bytearray(path.read_bytes()) + central = data.index(b"PK\x01\x02") + crc = zlib.crc32(b"{}\n") if forged else 1 + struct.pack_into(" Date: Sun, 15 Nov 2026 12:40:08 +0800 Subject: [PATCH 31/37] Bind six npm own-attribution texts to exact source inputs --- assets/notices/sources/expo-MIT.txt | 21 ++++++++ .../notices/sources/qrcode-generator-MIT.txt | 21 ++++++++ assets/notices/sources/react-native-MIT.txt | 21 ++++++++ docs/app-notices.md | 19 +++++++ scripts/app-notice-provenance.json | 6 +++ scripts/app-notices.mjs | 27 ++++++++++ scripts/app-notices.test.mjs | 50 +++++++++++++++++-- scripts/capture-app-notices.mjs | 7 +-- scripts/native-notice-inputs.mjs | 24 ++++++++- scripts/native-notice-inputs.test.mjs | 13 ++++- 10 files changed, 201 insertions(+), 8 deletions(-) create mode 100644 assets/notices/sources/expo-MIT.txt create mode 100644 assets/notices/sources/qrcode-generator-MIT.txt create mode 100644 assets/notices/sources/react-native-MIT.txt diff --git a/assets/notices/sources/expo-MIT.txt b/assets/notices/sources/expo-MIT.txt new file mode 100644 index 00000000..30b20e3b --- /dev/null +++ b/assets/notices/sources/expo-MIT.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/notices/sources/qrcode-generator-MIT.txt b/assets/notices/sources/qrcode-generator-MIT.txt new file mode 100644 index 00000000..150770c0 --- /dev/null +++ b/assets/notices/sources/qrcode-generator-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2009 Kazuhiko Arase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/notices/sources/react-native-MIT.txt b/assets/notices/sources/react-native-MIT.txt new file mode 100644 index 00000000..b93be905 --- /dev/null +++ b/assets/notices/sources/react-native-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/app-notices.md b/docs/app-notices.md index c259e85a..baf28522 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -225,6 +225,25 @@ When an AAR/JAR omits full text, an explicitly reviewed `additionalNoticeFiles` entry may point at an actual pinned upstream copy under `assets/notices/sources/` with `file`, `sha256`, `upstream`. It supplements discovered notices; it cannot replace a missing NOTICE with a package label or an assumed parent licence. + +Six exact npm records in `knownLicenseSources` also supplement missing own MIT +text: `@expo/ui`, the four React Native subpackages listed there, and +`qrcode-generator`. Their closed `ownText` selectors rehash the installed package +manifest, selected package files and committed text, and match the actual lock +version, tarball and integrity. Publisher `gitHead` and metadata SHA are manually +reviewed declarations included in the component fingerprint; capture does not +fetch or independently verify that remote metadata. The three pinned Expo, +React Native and qrcode text files are attribution inputs only. They preserve +discovered NOTICEs and vendor boundaries and do not approve any component, +vendor, generated input or retained resource. Component review must bind the +resulting text and provenance; seal and final verification recapture both. + +The three pending ExpoRouter, ExpoUI and Yoga Pod own-text bindings remain +inactive. An otherwise justified iOS discovery adds `actualSelectors` containing +only their fixed component labels, finite source kinds, match booleans and input +hashes. Unknown versions or source forms do not select a licence. This metadata +does not grant coverage or justify a separate discovery run. + Generated Metro sources permit only the known `__prelude__` and Expo CLI’s exact NUL-prefixed `polyfill:external-require`, `polyfill:environment-variables` and `polyfill:assets-registry` IDs, with their exact diff --git a/scripts/app-notice-provenance.json b/scripts/app-notice-provenance.json index b13a333c..cf6139a4 100644 --- a/scripts/app-notice-provenance.json +++ b/scripts/app-notice-provenance.json @@ -50,6 +50,12 @@ }, "packagedNative": {"ios": {}, "android": {}}, "knownLicenseSources": [ + {"component":"npm:@expo/ui@57.0.25","ecosystem":"npm","version":"57.0.25","public":true,"source":"assets/notices/sources/expo-MIT.txt","sha256":"fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4","upstream":"https://raw.githubusercontent.com/expo/expo/396da4641605bb19c1e6b3efff1cafaab8890a29/LICENSE","ownText":{"kind":"pinned-upstream-own-attribution","ownerNpmID":"npm:@expo/ui@57.0.25","packagePath":"node_modules/@expo/ui","packageJSONSource":"node_modules/@expo/ui/package.json","packageJSONSHA256":"ae8213d04de4e91cc31eae158fa665d0a54af316594d2bc65a8450efd8b18bf9","tarball":"https://registry.npmjs.org/@expo/ui/-/ui-57.0.25.tgz","integrity":"sha512-/BbxsKh/LSIFDnWAHRHfJdCcnqHY7WNcWXjld0soKnYXDUbImjvsFK7y5dqq2XZTbOMyf44BpgAWKUR/B8huCA==","publisher":{"gitHead":"396da4641605bb19c1e6b3efff1cafaab8890a29","metadataURL":"https://registry.npmjs.org/%40expo%2Fui/57.0.25","metadataSHA256":"983242821b956a65cb3644a36f71a88f042aa7a509107ac066f52e7b9b2421c5"},"selectedSources":[{"source":"node_modules/@expo/ui/ios/ExpoUI.podspec","sha256":"40ccef94d3947a4b8c181459637f205c556f36ef028fa1cd6629b387f89aca6d"}]}}, + {"component":"npm:@react-native/assets-registry@0.86.3","ecosystem":"npm","version":"0.86.3","public":true,"source":"assets/notices/sources/react-native-MIT.txt","sha256":"da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93","upstream":"https://raw.githubusercontent.com/react/react-native/4829a675c80e64f7c170b0f30d24b3808b7663d3/LICENSE","ownText":{"kind":"pinned-upstream-own-attribution","ownerNpmID":"npm:@react-native/assets-registry@0.86.3","packagePath":"node_modules/@react-native/assets-registry","packageJSONSource":"node_modules/@react-native/assets-registry/package.json","packageJSONSHA256":"89393bee843633fe8043019288736a8067382922c8e8bd3880ab23662ee72971","tarball":"https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.3.tgz","integrity":"sha512-TDhgCZA4wjJg84d5A9swiOQYPIWSKEEVdg9IwMFZDupQzW/F3QoLUrfAJOcalgqTDA9/buTB8awhE3Whwg6u9Q==","publisher":{"gitHead":"4829a675c80e64f7c170b0f30d24b3808b7663d3","metadataURL":"https://registry.npmjs.org/%40react-native%2Fassets-registry/0.86.3","metadataSHA256":"e23123fa9493f8114e30e1ebad2f39eccebf401ce7d8f6dc396a0aa3fc46ee1d"},"selectedSources":[{"source":"node_modules/@react-native/assets-registry/registry.js","sha256":"4edff3064f1d79e8a0dd866b9163e13fbb48bec4d3a832ad7673b076d799bd7c"}]}}, + {"component":"npm:@react-native/js-polyfills@0.86.3","ecosystem":"npm","version":"0.86.3","public":true,"source":"assets/notices/sources/react-native-MIT.txt","sha256":"da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93","upstream":"https://raw.githubusercontent.com/react/react-native/4829a675c80e64f7c170b0f30d24b3808b7663d3/LICENSE","ownText":{"kind":"pinned-upstream-own-attribution","ownerNpmID":"npm:@react-native/js-polyfills@0.86.3","packagePath":"node_modules/@react-native/js-polyfills","packageJSONSource":"node_modules/@react-native/js-polyfills/package.json","packageJSONSHA256":"86ff9ce670176a1f603e2b119f36893a7d824455f02ad639030ea05f8c6aaf33","tarball":"https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.86.3.tgz","integrity":"sha512-eYIJ0es967+tePBFQDnl/gidVFxLns3fnbiK6rxscQrGodvuUO6hwxpQnfNynJ8MjbbndImXihXjcnJdc7SzJg==","publisher":{"gitHead":"4829a675c80e64f7c170b0f30d24b3808b7663d3","metadataURL":"https://registry.npmjs.org/%40react-native%2Fjs-polyfills/0.86.3","metadataSHA256":"05cd0c253463e680b4ce3d6e8f6726690be460a1da59ef30370a0dafbea7614d"},"selectedSources":[{"source":"node_modules/@react-native/js-polyfills/index.js","sha256":"da03cd1a85b6e7018fabba50ed243292947e041a56b44bfe56cc07e1e6fd7944"}]}}, + {"component":"npm:@react-native/normalize-colors@0.86.3","ecosystem":"npm","version":"0.86.3","public":true,"source":"assets/notices/sources/react-native-MIT.txt","sha256":"da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93","upstream":"https://raw.githubusercontent.com/react/react-native/4829a675c80e64f7c170b0f30d24b3808b7663d3/LICENSE","ownText":{"kind":"pinned-upstream-own-attribution","ownerNpmID":"npm:@react-native/normalize-colors@0.86.3","packagePath":"node_modules/@react-native/normalize-colors","packageJSONSource":"node_modules/@react-native/normalize-colors/package.json","packageJSONSHA256":"37b0b78d9f920a0adab84199667e388a06605acfbdc45c0a8f7562504b2bade1","tarball":"https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.86.3.tgz","integrity":"sha512-Cv3CDkprb67GrzuaS9BGbBJC/6G4lIw3nyKOHRKTqTTum4bn37y5+R0Z04L8mcbQN85eEohNrRwb7IOM4j6uvg==","publisher":{"gitHead":"4829a675c80e64f7c170b0f30d24b3808b7663d3","metadataURL":"https://registry.npmjs.org/%40react-native%2Fnormalize-colors/0.86.3","metadataSHA256":"d1ebc04b795d05c61c928d5017e415079e1f251dd798f7c68f1c52c2d69533d8"},"selectedSources":[{"source":"node_modules/@react-native/normalize-colors/index.js","sha256":"2eef8bd869a6c11f2945ed9398aaeaaba6c9373b15e80a05ea43301a47d3847a"}]}}, + {"component":"npm:@react-native/virtualized-lists@0.86.3","ecosystem":"npm","version":"0.86.3","public":true,"source":"assets/notices/sources/react-native-MIT.txt","sha256":"da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93","upstream":"https://raw.githubusercontent.com/react/react-native/4829a675c80e64f7c170b0f30d24b3808b7663d3/LICENSE","ownText":{"kind":"pinned-upstream-own-attribution","ownerNpmID":"npm:@react-native/virtualized-lists@0.86.3","packagePath":"node_modules/@react-native/virtualized-lists","packageJSONSource":"node_modules/@react-native/virtualized-lists/package.json","packageJSONSHA256":"454a1a9ded25f68ae9962db2929d8a5c08050a2346983c5354901bbfa22b4852","tarball":"https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.86.3.tgz","integrity":"sha512-1j44NEyNn05Ut40vHAmoSWbsIcybFkMAOBTwQt1PrESyfSS+qBoyU1LGIogNva0VIa0rQyEC5PzbA7R4/7Nhyw==","publisher":{"gitHead":"4829a675c80e64f7c170b0f30d24b3808b7663d3","metadataURL":"https://registry.npmjs.org/%40react-native%2Fvirtualized-lists/0.86.3","metadataSHA256":"08c644e80b6fd0ef9cc8b962e564bf57a4ffcc3096bdd62628ad508eb719e60d"},"selectedSources":[{"source":"node_modules/@react-native/virtualized-lists/index.js","sha256":"8504b9c94898d7a8de8b20971a26232cc8287dee1a290954b2e302a881d7e69b"}]}}, + {"component":"npm:qrcode-generator@2.0.4","ecosystem":"npm","version":"2.0.4","public":true,"source":"assets/notices/sources/qrcode-generator-MIT.txt","sha256":"3a850fa5f08101db6f40676c2786e10bd2cd5fff7b12ffdf1e0c434d4e49d90c","upstream":"https://raw.githubusercontent.com/kazuhikoarase/qrcode-generator/83b7e8fe3fddd3b0368dbafd6ce56995bd25e3c8/LICENSE","ownText":{"kind":"pinned-upstream-own-attribution","ownerNpmID":"npm:qrcode-generator@2.0.4","packagePath":"node_modules/qrcode-generator","packageJSONSource":"node_modules/qrcode-generator/package.json","packageJSONSHA256":"0c8db18ad7f1b9dece93dd87dc0624be938f9b9b21e93c0f78e6e2d194ac10e9","tarball":"https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz","integrity":"sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==","publisher":{"gitHead":"83b7e8fe3fddd3b0368dbafd6ce56995bd25e3c8","metadataURL":"https://registry.npmjs.org/qrcode-generator/2.0.4","metadataSHA256":"57bc334458e43210fde1afcb26dec1a4403c0374d1918a7751d83d58a5800792"},"selectedSources":[{"source":"node_modules/qrcode-generator/dist/qrcode.js","sha256":"79ec86f82856005b1c887905cfccfcfbec3821ca61c7fd5a952faa5f778f791c"},{"source":"node_modules/qrcode-generator/dist/qrcode.mjs","sha256":"ea91d7118a5395289170da848b7c6758b996163bfbccf312591ab65a4911b7c0"}]}}, {"component":"cocoapods:ExpoAppleAuthentication@57.0.2","ecosystem":"cocoapods","version":"57.0.2","public":true,"source":"node_modules/expo-apple-authentication/LICENSE","sha256":"fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4","upstream":"https://registry.npmjs.org/expo-apple-authentication/-/expo-apple-authentication-57.0.2.tgz","upstreamIntegrity":"sha512-00KnlyRceRKski+ljZz8q8JhJZ4D3ZScU+Y0RqS6ElJ8GaCWfd7Qh5tMr47yIU/dKDrOnkzkFUM+6vyHS5HI7Q==","ownerNpmID":"npm:expo-apple-authentication@57.0.2","packagePath":"node_modules/expo-apple-authentication","packageJSONSource":"node_modules/expo-apple-authentication/package.json","packageJSONSHA256":"b5fa503ebfa4512d1e325431a1d7e008339cb75b97db35e9bf2f7e41a1fd9a06","podspecSource":"node_modules/expo-apple-authentication/ios/ExpoAppleAuthentication.podspec","podspecSHA256":"541092863ad1008c3e7b6823a6dde1e88739213a271a9cce4d7f7eff568e59aa","sourceKind":"path"}, {"component":"cocoapods:ExpoFileSystem@57.0.7","ecosystem":"cocoapods","version":"57.0.7","public":true,"source":"node_modules/expo-file-system/LICENSE","sha256":"fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4","upstream":"https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz","upstreamIntegrity":"sha512-1vkZRadOsIputFDBcclqBa1pO3iXOO6iIsdqJipQWw4V5TSnWgmRTuOF7ro8IzrVue+PHbum++KCG3Tawem4cg==","ownerNpmID":"npm:expo-file-system@57.0.7","packagePath":"node_modules/expo-file-system","packageJSONSource":"node_modules/expo-file-system/package.json","packageJSONSHA256":"db1d8ced7ce04e7daf4e55c3fd543bfbe42e32aa5e192bbb8ae4bda98de50069","podspecSource":"node_modules/expo-file-system/ios/ExpoFileSystem.podspec","podspecSHA256":"c651c016d967b778b8ccd4dea4490454a10ef6c27488d0f05ee9896ca45172f7","sourceKind":"podspec"}, {"component":"cocoapods:ExpoAdapterGoogleSignIn@16.1.5","ecosystem":"cocoapods","version":"16.1.5","public":true,"source":"node_modules/@react-native-google-signin/google-signin/LICENSE","sha256":"3680a16525f40f4115c71d7f229ed134afb6946e31ae2af83aa35ce0bc58d539","upstream":"https://registry.npmjs.org/@react-native-google-signin/google-signin/-/google-signin-16.1.5.tgz","upstreamIntegrity":"sha512-9rmMwACzHK9Cbd0Yp7efg5uWgiYoLwmNAJh4gRCuMcSQH+qplpK7E4d1Q4tbsD8M9jvvVtRX2Z0AP9wwBKmPrQ==","ownerNpmID":"npm:@react-native-google-signin/google-signin@16.1.5","packagePath":"node_modules/@react-native-google-signin/google-signin","packageJSONSource":"node_modules/@react-native-google-signin/google-signin/package.json","packageJSONSHA256":"8007e4f26f1b1a34a1621f713d8d4bb4a7170f2d976299e14721b74068989727","podspecSource":"node_modules/@react-native-google-signin/google-signin/expo/ios/ExpoAdapterGoogleSignIn.podspec","podspecSHA256":"308fdad5a2016a4c2eb9a24affcdeffb0dbf56e914938843c443d1524b1ed494","sourceKind":"path"}, diff --git a/scripts/app-notices.mjs b/scripts/app-notices.mjs index 0abf3780..04ef61b1 100644 --- a/scripts/app-notices.mjs +++ b/scripts/app-notices.mjs @@ -144,6 +144,33 @@ export function npmNoticeInputs(root,modulePaths){ }).sort((a,b)=>a.id.localeCompare(b.id)); } +// Explicit own-text approval is separate from component/vendor coverage. Publisher +// metadata is a manually reviewed declaration, not an unfetched runtime check. +export function addKnownOwnNoticeFiles(root,components,ledger){ + const exact=(value,keys)=>assert.deepEqual(Object.keys(value??{}).sort(),[...keys].sort(),'Unsupported own-text selector.'); + const pins=(ledger.knownLicenseSources??[]).filter(pin=>pin.ownText),seen=new Set(); + for(const pin of pins){assert.ok(!seen.has(pin.component));seen.add(pin.component);assert.equal(pin.ecosystem,'npm');assert.equal(pin.public,true);} + const project=realpathSync(root),modules=pins.length?realpathSync(join(root,'node_modules')):null,lock=pins.length?JSON.parse(readFileSync(join(root,'package-lock.json'))).packages:{}; + return components.map(item=>{ + const pin=pins.find(value=>value.component===item.id);if(!pin)return item; + const own=pin.ownText;exact(own,['kind','ownerNpmID','packagePath','packageJSONSource','packageJSONSHA256','tarball','integrity','publisher','selectedSources']); + assert.equal(own.kind,'pinned-upstream-own-attribution');assert.equal(item.ecosystem,'npm');assert.equal(item.version,pin.version);assert.equal(item.id,own.ownerNpmID); + assert.equal(item.packagePath,own.packagePath);assert.equal(own.packagePath,'node_modules/'+item.name);assert.equal(own.ownerNpmID,`npm:${item.name}@${item.version}`); + const owner=realpathSync(join(project,safeRelative(own.packagePath)));assert.equal(owner,join(modules,item.name));assert.ok(inside(modules,owner)); + const checked=(source,expected)=>{safeRelative(source);const logical=join(project,source),path=realpathSync(logical);assert.ok(inside(owner,path)&&path!==owner);assert.ok(lstatSync(logical).isFile()&&!lstatSync(logical).isSymbolicLink());assert.equal(path,join(owner,relative(join(project,own.packagePath),logical)));const bytes=readFileSync(path);assert.match(expected??'',/^[a-f0-9]{64}$/);assert.equal(digest(bytes),expected,'Own-text source bytes changed.');return bytes;}; + assert.equal(own.packageJSONSource,own.packagePath+'/package.json');const pkg=JSON.parse(noticeText(checked(own.packageJSONSource,own.packageJSONSHA256))); + assert.equal(pkg.name,item.name);assert.equal(pkg.version,item.version);assert.equal(pkg.license,'MIT');assert.equal(lock[own.packagePath]?.version,item.version);assert.equal(lock[own.packagePath]?.resolved,own.tarball);assert.equal(lock[own.packagePath]?.integrity,own.integrity);assert.match(own.integrity,/^sha512-[A-Za-z0-9+/]{86}==$/);assert.equal(own.tarball,`https://registry.npmjs.org/${item.name}/-/${item.name.split('/').at(-1)}-${item.version}.tgz`); + exact(own.publisher,['gitHead','metadataURL','metadataSHA256']);assert.match(own.publisher.gitHead,/^[a-f0-9]{40}$/);assert.match(own.publisher.metadataSHA256,/^[a-f0-9]{64}$/); + assert.equal(own.publisher.metadataURL,`https://registry.npmjs.org/${encodeURIComponent(item.name)}/${item.version}`); + const repository=new URL((typeof pkg.repository==='string'?pkg.repository:pkg.repository?.url??'').replace(/^git\+/,'').replace(/\.git$/,''));assert.equal(repository.protocol,'https:');assert.equal(repository.hostname,'github.com');assert.ok(!repository.username&&!repository.password&&!repository.search&&!repository.hash&&/^\/[^/]+\/[^/]+$/.test(repository.pathname)); + assert.equal(pin.upstream,`https://raw.githubusercontent.com${repository.pathname}/${own.publisher.gitHead}/LICENSE`); + assert.ok(Array.isArray(own.selectedSources)&&own.selectedSources.length>0&&own.selectedSources.length<=8);const selected=new Set();for(const value of own.selectedSources){exact(value,['source','sha256']);assert.ok(!selected.has(value.source));selected.add(value.source);checked(value.source,value.sha256);} + safeRelative(pin.source);assert.ok(pin.source.startsWith('assets/notices/sources/'));const path=join(project,pin.source);assert.equal(realpathSync(path),path);assert.ok(lstatSync(path).isFile()&&!lstatSync(path).isSymbolicLink()); + const notice=candidate(project,path,pin.source);assert.equal(notice.sha256,pin.sha256,'Own-text licence bytes changed.');assert.ok(!item.notices.some(value=>value.source===notice.source));assert.ok(!Object.hasOwn(item.nativeInputs??{},'ownAttribution')); + return {...item,notices:[...item.notices,notice].sort((a,b)=>a.source.localeCompare(b.source)),nativeInputs:{...item.nativeInputs,ownAttribution:{kind:own.kind,ownerNpmID:own.ownerNpmID,packagePath:own.packagePath,packageJSON:{source:own.packageJSONSource,sha256:own.packageJSONSHA256},tarball:own.tarball,integrity:own.integrity,publisherDeclaration:{...own.publisher},selectedSources:own.selectedSources.map(value=>({...value})),text:{source:pin.source,sha256:pin.sha256,upstream:pin.upstream}}}}; + }); +} + // Native adapters must supply actual resolved coordinates and actual text, // never a POM SPDX label or an assumed parent package's permission notice. export function nativeNoticeInput({id,name,version,ecosystem,notices,vendors=[],vendorNotices={},nativeInputs={}}){ diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs index a5242ec5..02923996 100644 --- a/scripts/app-notices.test.mjs +++ b/scripts/app-notices.test.mjs @@ -6,7 +6,7 @@ import {join} from 'node:path'; import {fileURLToPath} from 'node:url'; import test from 'node:test'; import {parse} from 'yaml'; -import {npmNoticeInputs,nativeNoticeInput,resolveNoticeCoverage,assertSourceNoticePayload,publicNoticeReport,assertFinalNoticeEvidence,capturedSource,assertCapturedSources,assertFinalSourceMap,assertPackagedNativeCoverage,digest,unreviewedOSSNoticeInputs} from './app-notices.mjs'; +import {addKnownOwnNoticeFiles,npmNoticeInputs,nativeNoticeInput,resolveNoticeCoverage,assertSourceNoticePayload,publicNoticeReport,assertFinalNoticeEvidence,capturedSource,assertCapturedSources,assertFinalSourceMap,assertPackagedNativeCoverage,digest,unreviewedOSSNoticeInputs} from './app-notices.mjs'; import {captureResolvedInputs,createNoticeSeal} from './capture-app-notices.mjs'; const mit=readFileSync(new URL('../node_modules/react-native/LICENSE',import.meta.url),'utf8'); @@ -151,11 +151,16 @@ test('actual verify CLI derives final components from map/native inputs and reje const aar=file('build/source.aar',''),staticJar=file('build/static.jar',''); execFileSync('python3',['-c',`import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:z.writestr('LICENSE',sys.argv[3]);z.writestr('jni/arm64-v8a/libqa.so',b'\\x7fELFcontrolled')\nwith zipfile.ZipFile(sys.argv[2],'w') as z:z.writestr('LICENSE',sys.argv[3])`,aar,staticJar,mit]); const gradle=file('build/runtime-inputs.json',{schema:1,configuration:'releaseRuntimeClasspath',components:[{group:'qa',name:'native',version:'1',artifacts:[{file:aar,sha256:digest(readFileSync(aar))}]},{group:'qa',name:'static',version:'1',artifacts:[{file:staticJar,sha256:digest(readFileSync(staticJar))}]}]}); - const empty={schema:1,components:[],knownLicenseSources:[],retainedAndroidResources:{'META-INF/NOTICE.json':digest('{"notice":"Required synthetic native notice"}\n')}};file('scripts/app-notice-provenance.json',empty); + const ownSource='assets/notices/sources/qa-MIT.txt';file(ownSource,mit);const ownPackage={name:'qa',version:'1',license:'MIT',repository:'https://github.com/qa/library.git'};file('node_modules/qa/package.json',ownPackage); + const tarball='https://registry.npmjs.org/qa/-/qa-1.tgz',integrity='sha512-'+Buffer.alloc(64).toString('base64');lock.packages['node_modules/qa']={version:'1',resolved:tarball,integrity};file('package-lock.json',lock); + const ownPin={component:'npm:qa@1',ecosystem:'npm',version:'1',public:true,source:ownSource,sha256:digest(mit),upstream:'https://raw.githubusercontent.com/qa/library/'+ 'a'.repeat(40)+'/LICENSE',ownText:{kind:'pinned-upstream-own-attribution',ownerNpmID:'npm:qa@1',packagePath:'node_modules/qa',packageJSONSource:'node_modules/qa/package.json',packageJSONSHA256:digest(readFileSync(join(root,'node_modules/qa/package.json'))),tarball,integrity,publisher:{gitHead:'a'.repeat(40),metadataURL:'https://registry.npmjs.org/qa/1',metadataSHA256:'b'.repeat(64)},selectedSources:[{source:'node_modules/qa/index.js',sha256:digest(moduleCode)}]}}; + const empty={schema:1,components:[],knownLicenseSources:[ownPin],retainedAndroidResources:{'META-INF/NOTICE.json':digest('{"notice":"Required synthetic native notice"}\n')}};file('scripts/app-notice-provenance.json',empty); const paths=[appPath,modulePath,assetPath,join(root,'assets/notices/third_party_notices.json')]; const initial=captureResolvedInputs(root,'android',paths,gradle),approved={...empty,components:initial.noticeInputs.map(item=>review(item,'android'))};file('scripts/app-notice-provenance.json',approved); const coverage=resolveNoticeCoverage(initial.noticeInputs,approved,'android');file('assets/notices/third_party_notices.json',sourcePayload(coverage)); - runGit('init','--quiet');runGit('add','.');runGit('-c','user.name=Notice test','-c','user.email=notice-test@example.invalid','commit','--quiet','-m','Synthetic notice fixture'); + runGit('init','--quiet');runGit('add','.');runGit('reset','--',ownSource);runGit('-c','user.name=Notice test','-c','user.email=notice-test@example.invalid','commit','--quiet','-m','Synthetic notice fixture'); + assert.throws(()=>createNoticeSeal(root,'android',captureResolvedInputs(root,'android',paths,gradle)),'Even exact approved own text must be committed, not just untracked readable bytes.'); + runGit('add',ownSource);runGit('-c','user.name=Notice test','-c','user.email=notice-test@example.invalid','commit','--quiet','-m','Commit synthetic own text'); const capture=captureResolvedInputs(root,'android',paths,gradle),seal=createNoticeSeal(root,'android',capture);file('build/notices/android/seal.json',seal); for(const name of ['app.json','package-lock.json']){const before=readFileSync(join(root,name));file(name,Buffer.concat([before,Buffer.from(' ')]));assert.throws(()=>createNoticeSeal(root,'android',capture));file(name,before);} assert.throws(()=>createNoticeSeal(join(root,'node_modules'), 'android',capture)); @@ -164,6 +169,8 @@ test('actual verify CLI derives final components from map/native inputs and reje const pack=(extra=false,emptyRetained=false)=>execFileSync('python3',['-c',`import zipfile,sys\nwith zipfile.ZipFile(sys.argv[1],'w') as z:\n z.write(sys.argv[2],'assets/third_party_notices.json');z.write(sys.argv[3],'assets/index.android.bundle');z.writestr('lib/arm64-v8a/libqa.so',b'\\x7fELFcontrolled');z.writestr('assets/font.ttf','synthetic font bytes');z.writestr('third_party_licenses.txt',b'');z.writestr('third_party_licenses.json',sys.argv[7]);z.writestr('META-INF/NOTICE.json',b'' if sys.argv[5]=='yes' else sys.argv[7] if sys.argv[5]=='json' else sys.argv[6])\n if sys.argv[4]=='yes':z.writestr('assets/unknown-executable',b'\\xcf\\xfa\\xed\\xfecontrolled')`,apk,join(root,'assets/notices/third_party_notices.json'),bundle,extra?'yes':'no',emptyRetained===true?'yes':emptyRetained==='json'?'json':'no','{"notice":"Required synthetic native notice"}\n','{}\n']); const verify=()=>spawnSync(process.execPath,[fileURLToPath(new URL('./capture-app-notices.mjs',import.meta.url)),'verify','android','--gradle-input',gradle,'--archive',apk,'--bundle',bundle,'--source-map',map],{cwd:root,encoding:'utf8',timeout:30000}); pack();assert.equal(verify().status,0); + const changedDeclaration=structuredClone(approved);changedDeclaration.knownLicenseSources[0].ownText.publisher.metadataSHA256='c'.repeat(64);file('scripts/app-notice-provenance.json',changedDeclaration);assert.equal(verify().status,1,'Final reinspection rejects changed own-text publisher provenance.');file('scripts/app-notice-provenance.json',approved); + const ownBytes=readFileSync(join(root,ownSource));file(ownSource,Buffer.concat([ownBytes,Buffer.from('changed')]));assert.equal(verify().status,1,'Final reinspection rejects changed own-text bytes.');file(ownSource,ownBytes);assert.equal(verify().status,0); file('babel.config.cjs','module.exports={presets:["changed-preset"]};\n'); assert.deepEqual(captureResolvedInputs(root,'android',paths,gradle).sources,capture.sources,'The selected source paths/bytes remain identical.'); assert.throws(()=>createNoticeSeal(root,'android',capture),'Unselected dirty tracked build configuration cannot be labeled HEAD.');assert.equal(verify().status,1); @@ -328,3 +335,40 @@ test('actual pinned native polyfills and this app font asset route prove the two for(const platform of ['ios','android'])assert.deepEqual(extended.resolver.resolveRequest({originModulePath:join(process.cwd(),'node_modules/@expo/vector-icons/build/vendor/react-native-vector-icons/Fonts/MaterialCommunityIcons.ttf'),dev:false,customResolverOptions:{},resolveRequest(){throw new Error('Unintercepted asset request');}},registry,platform),{type:'sourceFile',filePath:'\0polyfill:assets-registry'}); assert.ok(bundler._depGraph._fileSystem.expoVirtualModules.has('\0polyfill:assets-registry')); }); + +function knownOwnTextFixture(run){return fixture(({root,file})=>{ + const provenance=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),pins=provenance.knownLicenseSources.filter(value=>value.ownText),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),paths=[]; + assert.equal(pins.length,6);file('package-lock.json',{packages:Object.fromEntries(pins.map(pin=>[pin.ownText.packagePath,installed.packages[pin.ownText.packagePath]]))}); + for(const pin of pins){for(const source of [pin.ownText.packageJSONSource,...pin.ownText.selectedSources.map(value=>value.source),pin.source])file(source,readFileSync(new URL('../'+source,import.meta.url)));paths.push(join(root,pin.ownText.selectedSources[0].source));} + const raw=()=>npmNoticeInputs(root,paths),capture=(ledger=provenance)=>addKnownOwnNoticeFiles(root,raw(),ledger); + return run({root,file,provenance,pins,paths,raw,capture}); +});} + +test('six exact npm own-text records append public attribution without component, parent or vendor approval',()=>knownOwnTextFixture(({root,provenance,pins,raw,capture})=>{ + const before=raw(),items=capture();assert.equal(items.length,6);assert.equal(provenance.components.length,0);assert.ok(before.every(item=>item.notices.length===0)); + for(const item of items){const pin=pins.find(pin=>pin.component===item.id);assert.equal(item.notices.length,1);assert.equal(item.notices[0].sha256,pin.sha256);assert.ok(Buffer.from(item.notices[0].body).equals(readFileSync(join(root,pin.source))));assert.equal(item.nativeInputs.ownAttribution.ownerNpmID,item.id);assert.deepEqual(item.nativeInputs.ownAttribution.publisherDeclaration,pin.ownText.publisher);assert.deepEqual(item.vendors,before.find(value=>value.id===item.id).vendors);} + const report=unreviewedOSSNoticeInputs(items,provenance);assert.equal(report.components.length,6);assert.equal(report.complete,false);assert.ok(!JSON.stringify(report).includes(root));assert.equal(resolveNoticeCoverage(items,provenance).status,'INCOMPLETE');assert.ok(resolveNoticeCoverage(items,provenance).unresolved.every(row=>row.reason==='unreviewed-component')); + assert.equal(unreviewedOSSNoticeInputs(items,{...provenance,knownLicenseSources:[]}).components.length,0); + for(const component of ['cocoapods:ExpoRouter@57.0.28','cocoapods:ExpoUI@57.0.25','cocoapods:Yoga@0.0.0'])assert.ok(!provenance.knownLicenseSources.some(pin=>pin.component===component)); +})); + +test('own-text matched selectors reject changed bytes, owners, lock pins and unknown fields without fallback',()=>knownOwnTextFixture(({root,file,provenance,pins,raw,capture})=>{ + const pin=pins[0],mutate=(source,body)=>{const path=join(root,source),before=readFileSync(path);writeFileSync(path,body);assert.throws(capture);writeFileSync(path,before);}; + for(const source of [pin.source,pin.ownText.packageJSONSource,pin.ownText.selectedSources[0].source])mutate(source,Buffer.concat([readFileSync(join(root,source)),Buffer.from('changed')])); + mutate(pin.source,'');const lock=JSON.parse(readFileSync(join(root,'package-lock.json')));for(const field of ['version','resolved','integrity']){const changed=structuredClone(lock);changed.packages[pin.ownText.packagePath][field]='changed';mutate('package-lock.json',JSON.stringify(changed));} + for(const update of [p=>p.ownText.kind='unknown',p=>p.ownText.ownerNpmID='npm:other@1',p=>p.ownText.packagePath='node_modules/other',p=>p.ownText.publisher.gitHead='a'.repeat(40),p=>p.ownText.publisher.metadataURL='https://private.invalid/',p=>p.ownText.extra='PRIVATE',p=>p.ownText.selectedSources[0].source='node_modules/other/private.js',p=>p.source='../PRIVATE']){const changed=structuredClone(provenance);update(changed.knownLicenseSources.find(value=>value.component===pin.component));assert.throws(()=>capture(changed));} + const old=join(root,pin.source),body=readFileSync(old);rmSync(old);symlinkSync(file('outside-private.txt',body),old);assert.throws(capture);rmSync(old);file(pin.source,body); + const foreign=file('node_modules/unknown/index.js','source');file('node_modules/unknown/package.json',{name:'unknown',version:'1',license:'MIT'});file('node_modules/unknown/LICENSE',mit);const changed=JSON.parse(readFileSync(join(root,'package-lock.json')));changed.packages['node_modules/unknown']={version:'1'};file('package-lock.json',changed);const unknown=npmNoticeInputs(root,[foreign]);assert.deepEqual(addKnownOwnNoticeFiles(root,unknown,provenance),unknown); + const current=raw()[0],version={...current,id:`npm:${current.name}@999`,version:'999'};assert.equal(addKnownOwnNoticeFiles(root,[version],provenance)[0].nativeInputs,undefined); +})); + +test('reviewed publisher declarations and text-source provenance are fingerprinted, not runtime metadata verification',()=>knownOwnTextFixture(({provenance,pins,capture})=>{ + const items=capture(),item=items.find(item=>item.id===pins[0].component),approved={schema:1,components:[review(item)]};assert.equal(resolveNoticeCoverage([item],approved).status,'READY'); + for(const change of [pin=>pin.ownText.publisher.metadataSHA256='f'.repeat(64),pin=>{pin.ownText.publisher.gitHead='e'.repeat(40);pin.upstream=pin.upstream.replace(/[a-f0-9]{40}/,'e'.repeat(40));}]){const declaration=structuredClone(provenance);change(declaration.knownLicenseSources.find(pin=>pin.component===item.id));const changed=capture(declaration).find(value=>value.id===item.id);assert.equal(resolveNoticeCoverage([changed],approved).unresolved[0].reason,'native-source-digest-mismatch');} + const removed={...item,nativeInputs:{}};assert.equal(resolveNoticeCoverage([removed],approved).unresolved[0].reason,'native-source-digest-mismatch');const textDropped={...item,notices:[]};assert.equal(resolveNoticeCoverage([textDropped],approved).unresolved[0].reason,'missing-notice-text'); +})); + +test('injected own text preserves discovered NOTICEs and cannot satisfy vendored-source attribution',()=>knownOwnTextFixture(({root,file,provenance,pins})=>{ + const pin=pins[0],source=file(pin.ownText.packagePath+'/vendor/separate/index.js','vendored source');file(pin.ownText.packagePath+'/NOTICE','Separate preserved NOTICE'); + const original=npmNoticeInputs(root,[source])[0],item=addKnownOwnNoticeFiles(root,[original],provenance)[0];assert.ok(item.notices.some(value=>value.source===original.notices[0].source&&value.sha256===original.notices[0].sha256));assert.deepEqual(item.vendorNotices,original.vendorNotices);assert.equal(resolveNoticeCoverage([item],{schema:1,components:[review(item)]}).unresolved[0].reason,'missing-vendored-notice'); +})); diff --git a/scripts/capture-app-notices.mjs b/scripts/capture-app-notices.mjs index e7335054..176f4f84 100644 --- a/scripts/capture-app-notices.mjs +++ b/scripts/capture-app-notices.mjs @@ -5,7 +5,7 @@ import {chmodSync,existsSync,lstatSync,mkdirSync,readFileSync,realpathSync,rmSyn import {createRequire} from 'node:module'; import {dirname,join,relative,resolve,sep} from 'node:path'; import {fileURLToPath} from 'node:url'; -import {addReviewedNoticeFiles,assertCapturedSources,assertFinalNoticeEvidence,assertFinalSourceMap,assertPackagedNativeCoverage,assertSourceNoticePayload,capturedSource,digest,npmNoticeInputs,noticeText,publicNoticeReport,resolveNoticeCoverage,unreviewedOSSNoticeInputs} from './app-notices.mjs'; +import {addKnownOwnNoticeFiles,addReviewedNoticeFiles,assertCapturedSources,assertFinalNoticeEvidence,assertFinalSourceMap,assertPackagedNativeCoverage,assertSourceNoticePayload,capturedSource,digest,npmNoticeInputs,noticeText,publicNoticeReport,resolveNoticeCoverage,unreviewedOSSNoticeInputs} from './app-notices.mjs'; import {gradleNoticeInputs,inspectNoticeArchive,podNoticeInputs,unionNoticeInputs,noticeInputStep,noticeFailureContext} from './native-notice-inputs.mjs'; import {installTaskCleanup} from './native-task-cleanup.mjs'; @@ -34,7 +34,7 @@ export function captureResolvedInputs(root,platform,paths,gradleInput){ const npm=noticeInputStep('npm-notices','npm',()=>npmNoticeInputs(root,physical)); const union=noticeInputStep('notice-union','review',()=>unionNoticeInputs(npm,native.components)); const components=noticeInputStep('reviewed-notices','review',()=>{ - const result=addReviewedNoticeFiles(root,union,ledger,platform); + const result=addReviewedNoticeFiles(root,addKnownOwnNoticeFiles(root,union,ledger),ledger,platform); assert.ok(result.length<=2048&&result.reduce((bytes,item)=>bytes+item.notices.reduce((sum,value)=>sum+Buffer.byteLength(value.body),0),0)<=16*1024*1024,'Notice inputs exceed the review bound.');return result; }); const coverage=noticeInputStep('coverage','review',()=>resolveNoticeCoverage(components,ledger,platform));coverage.unresolved.push(...unresolved,...native.unresolved); @@ -68,6 +68,7 @@ export function assertReviewedSource(root,capture){ for(const path of ['package.json','package-lock.json','app.json',ledgerPath,fontPath,payloadPath])strictSource(root,path); for(const [path,expected] of Object.entries(fonts.files))assert.equal(digest(readFileSync(join(root,path))),expected,'Installed font input drifted.'); for(const component of ledger.components)for(const item of component.additionalNoticeFiles??[])strictSource(root,item.file); + for(const item of capture.noticeInputs)if(item.nativeInputs?.ownAttribution)strictSource(root,item.nativeInputs.ownAttribution.text.source); const payload=readFileSync(join(root,payloadPath));assertSourceNoticePayload(payload,coverage,ledger,fonts); assert.ok(sources.some(value=>value.path===payloadPath&&value.sha256===digest(payload)),'The offline view must import the same committed resource.'); const app=readJSON(join(root,'app.json')).expo; @@ -156,7 +157,7 @@ export async function main(args=process.argv.slice(2)){ const privateRoot=join(out,'private');mkdirSync(privateRoot,{recursive:true,mode:0o700});chmodSync(privateRoot,0o700); const paths=await metroPaths(root,platform,privateRoot);stage='native';const capture=captureResolvedInputs(root,platform,paths,options['--gradle-input']); const report=publicNoticeReport({platform,sourceCommit,entry,lockSHA256:digest(readFileSync(join(root,'package-lock.json'))),coverage:capture.coverage}); - write(join(out,'discovery.json'),{...report,sources:capture.sources,inputHashes:inputHashes(root,capture)}); + write(join(out,'discovery.json'),{...report,sources:capture.sources,inputHashes:inputHashes(root,capture),...(platform==='ios'?{actualSelectors:capture.native.actualSelectors??[]}:{})}); write(join(privateRoot,'unreviewed-inputs.json'),{schema:1,status:'INCOMPLETE',complete:false,discoveryOnly:true,components:capture.noticeInputs}); const review=join(out,'review');mkdirSync(review,{recursive:true,mode:0o700});write(join(review,'unreviewed-oss-notice-inputs.json'),unreviewedOSSNoticeInputs(capture.noticeInputs,capture.ledger)); if(mode==='collect'){console.log(`Notices discovery: INCOMPLETE (${capture.coverage.unresolved.length} unresolved inputs); no release payload generated.`);return;} diff --git a/scripts/native-notice-inputs.mjs b/scripts/native-notice-inputs.mjs index 2a9e1d0e..c46ce61c 100644 --- a/scripts/native-notice-inputs.mjs +++ b/scripts/native-notice-inputs.mjs @@ -75,6 +75,28 @@ function podOwnLicense(root,name,version,source,external){ return {notice:{source:pin.source,sha256:pin.sha256,body},binding:{ownerNpmID:pin.ownerNpmID,packagePath,sourceKind:pin.sourceKind,packageJSON:{source:pin.packageJSONSource,sha256:pin.packageJSONSHA256},podspec:{source:pin.podspecSource,sha256:pin.podspecSHA256},license:{source:pin.source,sha256:pin.sha256},upstream:pin.upstream,upstreamIntegrity:pin.upstreamIntegrity}}; } +// Finite observations only, not licence bindings. These three pending Pod +// forms must be observed by an otherwise justified producer, never guessed. +export function podActualSelectors(root,lock,versions,podLockSHA256){ + assert.match(podLockSHA256,/^[a-f0-9]{64}$/); + return [['ExpoRouter','57.0.28','expo-router','57.0.28','ios/ExpoRouter.podspec'],['ExpoUI','57.0.25','@expo/ui','57.0.25','ios/ExpoUI.podspec'],['Yoga','0.0.0','react-native','0.86.3','ReactCommon/yoga/Yoga.podspec']].map(([name,version,npm,npmVersion,spec])=>{ + const value=lock['EXTERNAL SOURCES']?.[name],keys=value&&typeof value==='object'&&!Array.isArray(value)?Object.keys(value):[]; + const kind=value===undefined?'missing':keys.length>1?'ambiguous':keys.length===1&&[':path',':podspec'].includes(keys[0])&&typeof value[keys[0]]==='string'?keys[0].slice(1):'other'; + const result={component:`cocoapods:${name}@${version}`,resolvedVersionMatches:versions.get(name)===version,kind,canonicalOwnerMatches:false,selectedRelativePathMatches:false,podLockSHA256,ownerPackageJSONSHA256:null,selectedPodspecSHA256:null}; + try{ + const modules=realpathSync(join(root,'node_modules')),owner=realpathSync(join(root,'node_modules',npm));assert.equal(owner,join(modules,npm)); + const manifest=join(owner,'package.json');assert.equal(realpathSync(manifest),manifest);assert.ok(lstatSync(manifest).isFile()&&!lstatSync(manifest).isSymbolicLink());const bytes=readFileSync(manifest),pkg=JSON.parse(noticeText(bytes)); + assert.equal(pkg.name,npm);assert.equal(pkg.version,npmVersion);result.canonicalOwnerMatches=true;result.ownerPackageJSONSHA256=digest(bytes); + if(result.resolvedVersionMatches&&['path','podspec'].includes(kind)){ + const expected=join(owner,spec);assert.equal(realpathSync(expected),expected);assert.ok(lstatSync(expected).isFile()&&!lstatSync(expected).isSymbolicLink()); + result.selectedRelativePathMatches=realpathSync(resolve(root,'ios',value[':'+kind]))===(kind==='path'?dirname(expected):expected); + if(result.selectedRelativePathMatches)result.selectedPodspecSHA256=digest(readFileSync(expected)); + } + }catch{/* Never emit unknown source paths, lock values, package data or errors. */} + return result; + }); +} + export function podNoticeInputs(root){ const lockPath=join(root,'ios/Podfile.lock'),support=join(root,'ios/Pods/Target Support Files'); if(!existsSync(lockPath)||!existsSync(support))return {components:[],unresolved:[{component:'native:ios',reason:'missing-resolved-pod-inputs'}],nativeFiles:[]}; @@ -107,7 +129,7 @@ export function podNoticeInputs(root){ },`cocoapods:${name}@${version}`); components.push(noticeInputStep('pod-notices','cocoapods',()=>nativeNoticeInput({id:`cocoapods:${name}@${version}`,name,version,ecosystem:'cocoapods',...scan,notices:[...notices,...scan.notices].sort((a,b)=>a.source.localeCompare(b.source))}),`cocoapods:${name}@${version}`)); } - return {components,unresolved,nativeFiles:[],inputHashes:{podLock:digest(readFileSync(lockPath)),podAcknowledgements:digest(JSON.stringify(acknowledgements))}}; + return {components,unresolved,nativeFiles:[],actualSelectors:podActualSelectors(root,lock,versions,digest(readFileSync(lockPath))),inputHashes:{podLock:digest(readFileSync(lockPath)),podAcknowledgements:digest(JSON.stringify(acknowledgements))}}; } export function gradleNoticeInputs(root,inputFile){ if(!inputFile||!existsSync(inputFile))return {components:[],unresolved:[{component:'native:android',reason:'missing-resolved-gradle-inputs'}],nativeFiles:[]}; diff --git a/scripts/native-notice-inputs.test.mjs b/scripts/native-notice-inputs.test.mjs index 64963842..b599269c 100644 --- a/scripts/native-notice-inputs.test.mjs +++ b/scripts/native-notice-inputs.test.mjs @@ -5,7 +5,7 @@ import {tmpdir} from 'node:os'; import {join,dirname} from 'node:path'; import {createRequire} from 'node:module'; import test from 'node:test'; -import {podNoticeInputs,gradleNoticeInputs,unionNoticeInputs,inspectNoticeArchive,noticeInputStep,noticeFailureContext,podLinkedSources} from './native-notice-inputs.mjs'; +import {podNoticeInputs,gradleNoticeInputs,unionNoticeInputs,inspectNoticeArchive,noticeInputStep,noticeFailureContext,podLinkedSources,podActualSelectors} from './native-notice-inputs.mjs'; import {assertPackagedNativeCoverage,assertSourceNoticePayload,digest,npmNoticeInputs,resolveNoticeCoverage,nativeSourceNotices,unreviewedOSSNoticeInputs} from './app-notices.mjs'; const require=createRequire(import.meta.url),plist=require('@expo/plist'); const mit=readFileSync(new URL('../node_modules/react-native/LICENSE',import.meta.url),'utf8'); @@ -336,3 +336,14 @@ test('the pinned RN selector uses actual autolinking metadata, with app config a assert.deepEqual(selected.map(value=>{const pkg=JSON.parse(readFileSync(join(value.libraryPath,'package.json')));return 'npm:'+pkg.name+'@'+pkg.version;}).sort(),pins.libraries.filter(value=>value.codegen).map(value=>value.component).sort()); assert.deepEqual(pins.coreSourceTrees.map(value=>value.source),['node_modules/react-native/src','node_modules/react-native/React/FBReactNativeSpec']);assert.ok(JSON.parse(readFileSync('node_modules/react-native/package.json')).codegenConfig.libraries.some(value=>value.name==='FBReactNativeSpec')); })); + + +test('three pending Pod selectors expose only actual finite forms/owner matches/hashes and grant no own-text binding',()=>fixture(({root,file})=>{ + const targets=[['ExpoRouter','57.0.28','expo-router','57.0.28','ios/ExpoRouter.podspec'],['ExpoUI','57.0.25','@expo/ui','57.0.25','ios/ExpoUI.podspec'],['Yoga','0.0.0','react-native','0.86.3','ReactCommon/yoga/Yoga.podspec']],versions=new Map(),external={}; + for(const [name,version,npm,ownerVersion,spec] of targets){versions.set(name,version);file('node_modules/'+npm+'/package.json',{name:npm,version:ownerVersion});file('node_modules/'+npm+'/'+spec,'Public synthetic spec');external[name]={':path':'../node_modules/'+npm+'/'+dirname(spec)};} + const select=(values=external,current=versions)=>podActualSelectors(root,{'EXTERNAL SOURCES':values},current,'a'.repeat(64)); + const good=select();assert.equal(good.length,3);for(const row of good){assert.equal(row.kind,'path');assert.equal(row.resolvedVersionMatches,true);assert.equal(row.canonicalOwnerMatches,true);assert.equal(row.selectedRelativePathMatches,true);assert.match(row.ownerPackageJSONSHA256,/^[a-f0-9]{64}$/);assert.match(row.selectedPodspecSHA256,/^[a-f0-9]{64}$/);assert.deepEqual(Object.keys(row),['component','resolvedVersionMatches','kind','canonicalOwnerMatches','selectedRelativePathMatches','podLockSHA256','ownerPackageJSONSHA256','selectedPodspecSHA256']);} + const yoga=external.Yoga;for(const [value,kind] of [[undefined,'missing'],[{':path':yoga[':path'],':podspec':'SECRET_LOCK'},'ambiguous'],[{':git':'https://SECRET.invalid'},'other'],[{':path':'/PRIVATE/FOREIGN/PATH'},'path'],[{':podspec':'../node_modules/react-native/ReactCommon/yoga/Yoga.podspec'},'podspec']]){const result=select({...external,Yoga:value}).find(row=>row.component==='cocoapods:Yoga@0.0.0');assert.equal(result.kind,kind);assert.equal(result.selectedRelativePathMatches,kind==='podspec');assert.ok(!JSON.stringify(result).includes('PRIVATE')&&!JSON.stringify(result).includes('SECRET')&&!JSON.stringify(result).includes(root));} + const newer=new Map(versions);newer.set('Yoga','UNKNOWN_PRIVATE_VERSION');const wrongVersion=select(external,newer).at(-1);assert.equal(wrongVersion.resolvedVersionMatches,false);assert.equal(wrongVersion.selectedPodspecSHA256,null);assert.ok(!JSON.stringify(wrongVersion).includes('UNKNOWN')); + file('node_modules/react-native/package.json',{name:'PRIVATE_FOREIGN_OWNER',version:'0.86.3'});const foreign=select().at(-1);assert.equal(foreign.canonicalOwnerMatches,false);assert.equal(foreign.ownerPackageJSONSHA256,null);assert.equal(foreign.selectedPodspecSHA256,null);assert.ok(!JSON.stringify(foreign).includes('PRIVATE')); +})); From 82cede4b492a75a69bfbc9b4c27e3efb735b814c Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 13:17:18 +0800 Subject: [PATCH 32/37] Bind three explicit Pod own-license attributions to live selectors --- docs/app-notices.md | 21 ++++--- scripts/app-notice-provenance.json | 85 +++++++++++++++++++++++++++ scripts/app-notices.mjs | 23 +++++--- scripts/app-notices.test.mjs | 3 +- scripts/native-notice-inputs.mjs | 12 ++-- scripts/native-notice-inputs.test.mjs | 50 ++++++++++++++-- 6 files changed, 167 insertions(+), 27 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index baf28522..396269e6 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -10,9 +10,9 @@ The own-package source records additionally bind 97 selected RN/Expo Pods to lock-integrity-verified published tarballs; each reviewed podspec reads its own package licence. The 91 literal directory/6 exact podspec forms use the existing strict binding helper. These records preserve own-package attribution only. -ExpoRouter/ExpoUI without own text, Yoga, the six missing-text NPM components, -Metro prelude, and every vendor/generated boundary remain independently -unresolved. The component-approval ledger and offline payload are unchanged. +The additional six npm and three Pod own-text records below do not approve +component coverage. Metro prelude and every vendor/generated boundary remain +independently unresolved. The component ledger and offline payload are unchanged. ## Stable offline resource @@ -238,11 +238,16 @@ discovered NOTICEs and vendor boundaries and do not approve any component, vendor, generated input or retained resource. Component review must bind the resulting text and provenance; seal and final verification recapture both. -The three pending ExpoRouter, ExpoUI and Yoga Pod own-text bindings remain -inactive. An otherwise justified iOS discovery adds `actualSelectors` containing -only their fixed component labels, finite source kinds, match booleans and input -hashes. Unknown versions or source forms do not select a licence. This metadata -does not grant coverage or justify a separate discovery run. +Three explicit Pod records additionally bind Yoga to its locked `react-native` +package's original LICENSE, and ExpoRouter/ExpoUI to the reviewed Expo MIT text. +Every capture reparses its actual Pod lock and requires the exact selected +`:path`, installed owner and pinned podspec bytes. The same-capture finite +`actualSelectors` evidence, including the Pod-lock hash, is retained in each +component's `nativeInputs`; it cannot be supplied by a historical summary. +Unknown siblings/versions gain no binding; changed forms, roots or owning bytes +fail closed. Own attribution cannot approve vendors, generated inputs or retained +resources (including ExpoFileSystem's three separate framework boundaries), +and does not justify a separate discovery run. Generated Metro sources permit only the known `__prelude__` and Expo CLI’s exact NUL-prefixed `polyfill:external-require`, `polyfill:environment-variables` and diff --git a/scripts/app-notice-provenance.json b/scripts/app-notice-provenance.json index cf6139a4..c0949590 100644 --- a/scripts/app-notice-provenance.json +++ b/scripts/app-notice-provenance.json @@ -1711,6 +1711,91 @@ "podspecSHA256": "a487ac5c74d9aef1b3c6fa527e3cf929d62e031ebaf587f4948813b6717ee086", "sourceKind": "podspec", "public": true + }, + { + "component": "cocoapods:ExpoRouter@57.0.28", + "ecosystem": "cocoapods", + "version": "57.0.28", + "public": true, + "source": "assets/notices/sources/expo-MIT.txt", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://raw.githubusercontent.com/expo/expo/396da4641605bb19c1e6b3efff1cafaab8890a29/LICENSE", + "ownText": { + "kind": "pinned-upstream-own-attribution", + "ownerNpmID": "npm:expo-router@57.0.28", + "packagePath": "node_modules/expo-router", + "packageJSONSource": "node_modules/expo-router/package.json", + "packageJSONSHA256": "88a89b7adfba45cbc5855d270395fc72009463d9ed92ced7a73020fdce556f02", + "tarball": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.28.tgz", + "integrity": "sha512-KrW7dGtZ1A9ocpt/Gq1RKczfoAkJ+2LrxweVcEq/FZcJ+eFsaKC9f60ER3uzWJ9n9mb+dbeSPewYOMzG6KMK2Q==", + "publisher": { + "gitHead": "396da4641605bb19c1e6b3efff1cafaab8890a29", + "metadataURL": "https://registry.npmjs.org/expo-router/57.0.28", + "metadataSHA256": "efe59387862cd01deb57732c17f1a1d4c7d512009679e75f421b45d16a04543b" + }, + "selectedSources": [ + { + "source": "node_modules/expo-router/ios/ExpoRouter.podspec", + "sha256": "fff64d64e4f52538977dc0571c4df05d5d217424bf01d4d7d4571671610ca955" + } + ], + "pod": { + "sourceKind": "path", + "podspecSource": "node_modules/expo-router/ios/ExpoRouter.podspec", + "podspecSHA256": "fff64d64e4f52538977dc0571c4df05d5d217424bf01d4d7d4571671610ca955" + } + } + }, + { + "component": "cocoapods:ExpoUI@57.0.25", + "ecosystem": "cocoapods", + "version": "57.0.25", + "public": true, + "source": "assets/notices/sources/expo-MIT.txt", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://raw.githubusercontent.com/expo/expo/396da4641605bb19c1e6b3efff1cafaab8890a29/LICENSE", + "ownText": { + "kind": "pinned-upstream-own-attribution", + "ownerNpmID": "npm:@expo/ui@57.0.25", + "packagePath": "node_modules/@expo/ui", + "packageJSONSource": "node_modules/@expo/ui/package.json", + "packageJSONSHA256": "ae8213d04de4e91cc31eae158fa665d0a54af316594d2bc65a8450efd8b18bf9", + "tarball": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.25.tgz", + "integrity": "sha512-/BbxsKh/LSIFDnWAHRHfJdCcnqHY7WNcWXjld0soKnYXDUbImjvsFK7y5dqq2XZTbOMyf44BpgAWKUR/B8huCA==", + "publisher": { + "gitHead": "396da4641605bb19c1e6b3efff1cafaab8890a29", + "metadataURL": "https://registry.npmjs.org/%40expo%2Fui/57.0.25", + "metadataSHA256": "983242821b956a65cb3644a36f71a88f042aa7a509107ac066f52e7b9b2421c5" + }, + "selectedSources": [ + { + "source": "node_modules/@expo/ui/ios/ExpoUI.podspec", + "sha256": "40ccef94d3947a4b8c181459637f205c556f36ef028fa1cd6629b387f89aca6d" + } + ], + "pod": { + "sourceKind": "path", + "podspecSource": "node_modules/@expo/ui/ios/ExpoUI.podspec", + "podspecSHA256": "40ccef94d3947a4b8c181459637f205c556f36ef028fa1cd6629b387f89aca6d" + } + } + }, + { + "component": "cocoapods:Yoga@0.0.0", + "ecosystem": "cocoapods", + "version": "0.0.0", + "public": true, + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "source": "node_modules/react-native/LICENSE", + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "packageJSONSource": "node_modules/react-native/package.json", + "packageJSONSHA256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144", + "sourceKind": "path", + "podspecSource": "node_modules/react-native/ReactCommon/yoga/Yoga.podspec", + "podspecSHA256": "64eb7b752d3d6eb02514d5763220453dbc3a6a0aa040f27614a4ba6371b4ba9f" } ], "retainedAndroidResources": { diff --git a/scripts/app-notices.mjs b/scripts/app-notices.mjs index 04ef61b1..bd9cdf63 100644 --- a/scripts/app-notices.mjs +++ b/scripts/app-notices.mjs @@ -149,25 +149,32 @@ export function npmNoticeInputs(root,modulePaths){ export function addKnownOwnNoticeFiles(root,components,ledger){ const exact=(value,keys)=>assert.deepEqual(Object.keys(value??{}).sort(),[...keys].sort(),'Unsupported own-text selector.'); const pins=(ledger.knownLicenseSources??[]).filter(pin=>pin.ownText),seen=new Set(); - for(const pin of pins){assert.ok(!seen.has(pin.component));seen.add(pin.component);assert.equal(pin.ecosystem,'npm');assert.equal(pin.public,true);} + for(const pin of pins){assert.ok(!seen.has(pin.component));seen.add(pin.component);assert.ok(['npm','cocoapods'].includes(pin.ecosystem));assert.equal(pin.public,true);} const project=realpathSync(root),modules=pins.length?realpathSync(join(root,'node_modules')):null,lock=pins.length?JSON.parse(readFileSync(join(root,'package-lock.json'))).packages:{}; return components.map(item=>{ const pin=pins.find(value=>value.component===item.id);if(!pin)return item; - const own=pin.ownText;exact(own,['kind','ownerNpmID','packagePath','packageJSONSource','packageJSONSHA256','tarball','integrity','publisher','selectedSources']); - assert.equal(own.kind,'pinned-upstream-own-attribution');assert.equal(item.ecosystem,'npm');assert.equal(item.version,pin.version);assert.equal(item.id,own.ownerNpmID); - assert.equal(item.packagePath,own.packagePath);assert.equal(own.packagePath,'node_modules/'+item.name);assert.equal(own.ownerNpmID,`npm:${item.name}@${item.version}`); - const owner=realpathSync(join(project,safeRelative(own.packagePath)));assert.equal(owner,join(modules,item.name));assert.ok(inside(modules,owner)); + const own=pin.ownText,isPod=pin.ecosystem==='cocoapods';exact(own,['kind','ownerNpmID','packagePath','packageJSONSource','packageJSONSHA256','tarball','integrity','publisher','selectedSources',...(isPod?['pod']:[])]); + assert.equal(own.kind,'pinned-upstream-own-attribution');assert.equal(item.ecosystem,pin.ecosystem);assert.equal(item.version,pin.version); + const owner=realpathSync(join(project,safeRelative(own.packagePath)));assert.ok(inside(modules,owner)); const checked=(source,expected)=>{safeRelative(source);const logical=join(project,source),path=realpathSync(logical);assert.ok(inside(owner,path)&&path!==owner);assert.ok(lstatSync(logical).isFile()&&!lstatSync(logical).isSymbolicLink());assert.equal(path,join(owner,relative(join(project,own.packagePath),logical)));const bytes=readFileSync(path);assert.match(expected??'',/^[a-f0-9]{64}$/);assert.equal(digest(bytes),expected,'Own-text source bytes changed.');return bytes;}; assert.equal(own.packageJSONSource,own.packagePath+'/package.json');const pkg=JSON.parse(noticeText(checked(own.packageJSONSource,own.packageJSONSHA256))); - assert.equal(pkg.name,item.name);assert.equal(pkg.version,item.version);assert.equal(pkg.license,'MIT');assert.equal(lock[own.packagePath]?.version,item.version);assert.equal(lock[own.packagePath]?.resolved,own.tarball);assert.equal(lock[own.packagePath]?.integrity,own.integrity);assert.match(own.integrity,/^sha512-[A-Za-z0-9+/]{86}==$/);assert.equal(own.tarball,`https://registry.npmjs.org/${item.name}/-/${item.name.split('/').at(-1)}-${item.version}.tgz`); + assert.equal(own.packagePath,'node_modules/'+pkg.name);assert.equal(owner,join(modules,pkg.name));assert.equal(own.ownerNpmID,`npm:${pkg.name}@${pkg.version}`); + if(!isPod){assert.equal(pkg.name,item.name);assert.equal(pkg.version,item.version);assert.equal(item.id,own.ownerNpmID);assert.equal(item.packagePath,own.packagePath);} + else{ + exact(own.pod,['sourceKind','podspecSource','podspecSHA256']);assert.equal(own.pod.sourceKind,'path'); + assert.equal(item.id,`cocoapods:${item.name}@${item.version}`);assert.deepEqual(own.selectedSources,[{source:own.pod.podspecSource,sha256:own.pod.podspecSHA256}]); + const selector=item.nativeInputs?.[`cocoapods:${item.name}`]?.actualSelector;assert.match(selector?.podLockSHA256??'',/^[a-f0-9]{64}$/); + assert.deepEqual(selector,{component:item.id,resolvedVersionMatches:true,kind:own.pod.sourceKind,canonicalOwnerMatches:true,selectedRelativePathMatches:true,podLockSHA256:selector.podLockSHA256,ownerPackageJSONSHA256:own.packageJSONSHA256,selectedPodspecSHA256:own.pod.podspecSHA256}); + } + assert.equal(pkg.license,'MIT');assert.equal(lock[own.packagePath]?.version,pkg.version);assert.equal(lock[own.packagePath]?.resolved,own.tarball);assert.equal(lock[own.packagePath]?.integrity,own.integrity);assert.match(own.integrity,/^sha512-[A-Za-z0-9+/]{86}==$/);assert.equal(own.tarball,`https://registry.npmjs.org/${pkg.name}/-/${pkg.name.split('/').at(-1)}-${pkg.version}.tgz`); exact(own.publisher,['gitHead','metadataURL','metadataSHA256']);assert.match(own.publisher.gitHead,/^[a-f0-9]{40}$/);assert.match(own.publisher.metadataSHA256,/^[a-f0-9]{64}$/); - assert.equal(own.publisher.metadataURL,`https://registry.npmjs.org/${encodeURIComponent(item.name)}/${item.version}`); + assert.equal(own.publisher.metadataURL,`https://registry.npmjs.org/${encodeURIComponent(pkg.name)}/${pkg.version}`); const repository=new URL((typeof pkg.repository==='string'?pkg.repository:pkg.repository?.url??'').replace(/^git\+/,'').replace(/\.git$/,''));assert.equal(repository.protocol,'https:');assert.equal(repository.hostname,'github.com');assert.ok(!repository.username&&!repository.password&&!repository.search&&!repository.hash&&/^\/[^/]+\/[^/]+$/.test(repository.pathname)); assert.equal(pin.upstream,`https://raw.githubusercontent.com${repository.pathname}/${own.publisher.gitHead}/LICENSE`); assert.ok(Array.isArray(own.selectedSources)&&own.selectedSources.length>0&&own.selectedSources.length<=8);const selected=new Set();for(const value of own.selectedSources){exact(value,['source','sha256']);assert.ok(!selected.has(value.source));selected.add(value.source);checked(value.source,value.sha256);} safeRelative(pin.source);assert.ok(pin.source.startsWith('assets/notices/sources/'));const path=join(project,pin.source);assert.equal(realpathSync(path),path);assert.ok(lstatSync(path).isFile()&&!lstatSync(path).isSymbolicLink()); const notice=candidate(project,path,pin.source);assert.equal(notice.sha256,pin.sha256,'Own-text licence bytes changed.');assert.ok(!item.notices.some(value=>value.source===notice.source));assert.ok(!Object.hasOwn(item.nativeInputs??{},'ownAttribution')); - return {...item,notices:[...item.notices,notice].sort((a,b)=>a.source.localeCompare(b.source)),nativeInputs:{...item.nativeInputs,ownAttribution:{kind:own.kind,ownerNpmID:own.ownerNpmID,packagePath:own.packagePath,packageJSON:{source:own.packageJSONSource,sha256:own.packageJSONSHA256},tarball:own.tarball,integrity:own.integrity,publisherDeclaration:{...own.publisher},selectedSources:own.selectedSources.map(value=>({...value})),text:{source:pin.source,sha256:pin.sha256,upstream:pin.upstream}}}}; + return {...item,notices:[...item.notices,notice].sort((a,b)=>a.source.localeCompare(b.source)),nativeInputs:{...item.nativeInputs,ownAttribution:{kind:own.kind,ownerNpmID:own.ownerNpmID,packagePath:own.packagePath,packageJSON:{source:own.packageJSONSource,sha256:own.packageJSONSHA256},tarball:own.tarball,integrity:own.integrity,publisherDeclaration:{...own.publisher},selectedSources:own.selectedSources.map(value=>({...value})),...(isPod?{pod:{...own.pod}}:{}),text:{source:pin.source,sha256:pin.sha256,upstream:pin.upstream}}}}; }); } diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs index 02923996..f835a0ae 100644 --- a/scripts/app-notices.test.mjs +++ b/scripts/app-notices.test.mjs @@ -337,7 +337,7 @@ test('actual pinned native polyfills and this app font asset route prove the two }); function knownOwnTextFixture(run){return fixture(({root,file})=>{ - const provenance=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),pins=provenance.knownLicenseSources.filter(value=>value.ownText),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),paths=[]; + const provenance=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),pins=provenance.knownLicenseSources.filter(value=>value.ownText&&value.ecosystem==='npm'),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),paths=[]; assert.equal(pins.length,6);file('package-lock.json',{packages:Object.fromEntries(pins.map(pin=>[pin.ownText.packagePath,installed.packages[pin.ownText.packagePath]]))}); for(const pin of pins){for(const source of [pin.ownText.packageJSONSource,...pin.ownText.selectedSources.map(value=>value.source),pin.source])file(source,readFileSync(new URL('../'+source,import.meta.url)));paths.push(join(root,pin.ownText.selectedSources[0].source));} const raw=()=>npmNoticeInputs(root,paths),capture=(ledger=provenance)=>addKnownOwnNoticeFiles(root,raw(),ledger); @@ -349,7 +349,6 @@ test('six exact npm own-text records append public attribution without component for(const item of items){const pin=pins.find(pin=>pin.component===item.id);assert.equal(item.notices.length,1);assert.equal(item.notices[0].sha256,pin.sha256);assert.ok(Buffer.from(item.notices[0].body).equals(readFileSync(join(root,pin.source))));assert.equal(item.nativeInputs.ownAttribution.ownerNpmID,item.id);assert.deepEqual(item.nativeInputs.ownAttribution.publisherDeclaration,pin.ownText.publisher);assert.deepEqual(item.vendors,before.find(value=>value.id===item.id).vendors);} const report=unreviewedOSSNoticeInputs(items,provenance);assert.equal(report.components.length,6);assert.equal(report.complete,false);assert.ok(!JSON.stringify(report).includes(root));assert.equal(resolveNoticeCoverage(items,provenance).status,'INCOMPLETE');assert.ok(resolveNoticeCoverage(items,provenance).unresolved.every(row=>row.reason==='unreviewed-component')); assert.equal(unreviewedOSSNoticeInputs(items,{...provenance,knownLicenseSources:[]}).components.length,0); - for(const component of ['cocoapods:ExpoRouter@57.0.28','cocoapods:ExpoUI@57.0.25','cocoapods:Yoga@0.0.0'])assert.ok(!provenance.knownLicenseSources.some(pin=>pin.component===component)); })); test('own-text matched selectors reject changed bytes, owners, lock pins and unknown fields without fallback',()=>knownOwnTextFixture(({root,file,provenance,pins,raw,capture})=>{ diff --git a/scripts/native-notice-inputs.mjs b/scripts/native-notice-inputs.mjs index c46ce61c..4a1f9f68 100644 --- a/scripts/native-notice-inputs.mjs +++ b/scripts/native-notice-inputs.mjs @@ -75,8 +75,8 @@ function podOwnLicense(root,name,version,source,external){ return {notice:{source:pin.source,sha256:pin.sha256,body},binding:{ownerNpmID:pin.ownerNpmID,packagePath,sourceKind:pin.sourceKind,packageJSON:{source:pin.packageJSONSource,sha256:pin.packageJSONSHA256},podspec:{source:pin.podspecSource,sha256:pin.podspecSHA256},license:{source:pin.source,sha256:pin.sha256},upstream:pin.upstream,upstreamIntegrity:pin.upstreamIntegrity}}; } -// Finite observations only, not licence bindings. These three pending Pod -// forms must be observed by an otherwise justified producer, never guessed. +// Finite observations alone grant no attribution. Explicit bindings must +// revalidate the actual selected form and owning bytes on every capture. export function podActualSelectors(root,lock,versions,podLockSHA256){ assert.match(podLockSHA256,/^[a-f0-9]{64}$/); return [['ExpoRouter','57.0.28','expo-router','57.0.28','ios/ExpoRouter.podspec'],['ExpoUI','57.0.25','@expo/ui','57.0.25','ios/ExpoUI.podspec'],['Yoga','0.0.0','react-native','0.86.3','ReactCommon/yoga/Yoga.podspec']].map(([name,version,npm,npmVersion,spec])=>{ @@ -100,7 +100,7 @@ export function podActualSelectors(root,lock,versions,podLockSHA256){ export function podNoticeInputs(root){ const lockPath=join(root,'ios/Podfile.lock'),support=join(root,'ios/Pods/Target Support Files'); if(!existsSync(lockPath)||!existsSync(support))return {components:[],unresolved:[{component:'native:ios',reason:'missing-resolved-pod-inputs'}],nativeFiles:[]}; - const lock=noticeInputStep('pod-lock','cocoapods',()=>require('yaml').parse(noticeText(readFileSync(lockPath),16*1024*1024))),plist=require('@expo/plist').default,versions=new Map(),texts=new Map(),acknowledgements=[]; + const [lock,podLockSHA256]=noticeInputStep('pod-lock','cocoapods',()=>{const bytes=readFileSync(lockPath);return [require('yaml').parse(noticeText(bytes,16*1024*1024)),digest(bytes)];}),plist=require('@expo/plist').default,versions=new Map(),texts=new Map(),acknowledgements=[]; noticeInputStep('pod-lock-entries','cocoapods',()=>{for(const row of lock.PODS??[]){ const value=typeof row==='string'?row:Object.keys(row)[0],match=value.match(/^([^\s(]+) \(([^\s)]+)/);assert.ok(match,'Unsupported resolved pod entry.'); const name=match[1].split('/')[0],version=match[2];assert.ok(!versions.has(name)||versions.get(name)===version);versions.set(name,version); @@ -117,6 +117,7 @@ export function podNoticeInputs(root){ } } }); + const actualSelectors=podActualSelectors(root,lock,versions,podLockSHA256); const unresolved=[],components=[]; for(const [name,version] of versions){ const notices=texts.get(name)??[],external=lock['EXTERNAL SOURCES']?.[name];let scan={vendors:[],vendorNotices:{},nativeInputs:{},notices:[]}; @@ -125,11 +126,12 @@ export function podNoticeInputs(root){ else scan=noticeInputStep('pod-source-scan','cocoapods',()=>{ const own=podOwnLicense(root,name,version,source,external),{linkedSources,...metadata}=podLinkedSources(root,name,source,external),value=nativeSourceNotices(root,source,`cocoapods:${name}`,{linkedSources}); if(own)value.notices.push(own.notice); - Object.assign(value.nativeInputs[`cocoapods:${name}`],metadata,...(own?[{ownLicense:own.binding}]:[]));return value; + const actualSelector=actualSelectors.find(row=>row.component===`cocoapods:${name}@${version}`); + Object.assign(value.nativeInputs[`cocoapods:${name}`],metadata,...(own?[{ownLicense:own.binding}]:[]),...(actualSelector?[{actualSelector}]:[]));return value; },`cocoapods:${name}@${version}`); components.push(noticeInputStep('pod-notices','cocoapods',()=>nativeNoticeInput({id:`cocoapods:${name}@${version}`,name,version,ecosystem:'cocoapods',...scan,notices:[...notices,...scan.notices].sort((a,b)=>a.source.localeCompare(b.source))}),`cocoapods:${name}@${version}`)); } - return {components,unresolved,nativeFiles:[],actualSelectors:podActualSelectors(root,lock,versions,digest(readFileSync(lockPath))),inputHashes:{podLock:digest(readFileSync(lockPath)),podAcknowledgements:digest(JSON.stringify(acknowledgements))}}; + return {components,unresolved,nativeFiles:[],actualSelectors,inputHashes:{podLock:podLockSHA256,podAcknowledgements:digest(JSON.stringify(acknowledgements))}}; } export function gradleNoticeInputs(root,inputFile){ if(!inputFile||!existsSync(inputFile))return {components:[],unresolved:[{component:'native:android',reason:'missing-resolved-gradle-inputs'}],nativeFiles:[]}; diff --git a/scripts/native-notice-inputs.test.mjs b/scripts/native-notice-inputs.test.mjs index b599269c..15bacba6 100644 --- a/scripts/native-notice-inputs.test.mjs +++ b/scripts/native-notice-inputs.test.mjs @@ -6,7 +6,8 @@ import {join,dirname} from 'node:path'; import {createRequire} from 'node:module'; import test from 'node:test'; import {podNoticeInputs,gradleNoticeInputs,unionNoticeInputs,inspectNoticeArchive,noticeInputStep,noticeFailureContext,podLinkedSources,podActualSelectors} from './native-notice-inputs.mjs'; -import {assertPackagedNativeCoverage,assertSourceNoticePayload,digest,npmNoticeInputs,resolveNoticeCoverage,nativeSourceNotices,unreviewedOSSNoticeInputs} from './app-notices.mjs'; +import {addKnownOwnNoticeFiles,assertPackagedNativeCoverage,assertSourceNoticePayload,digest,npmNoticeInputs,resolveNoticeCoverage,nativeSourceNotices,unreviewedOSSNoticeInputs} from './app-notices.mjs'; +import {captureResolvedInputs,createNoticeSeal} from './capture-app-notices.mjs'; const require=createRequire(import.meta.url),plist=require('@expo/plist'); const mit=readFileSync(new URL('../node_modules/react-native/LICENSE',import.meta.url),'utf8'); test('archive native hashing streams bounded bytes and preserves CRC, size and nonnative failure gates',()=>{ @@ -20,7 +21,7 @@ function fixture(run){ } const initialOwnComponents=new Set(['cocoapods:ExpoAppleAuthentication@57.0.2','cocoapods:ExpoFileSystem@57.0.7','cocoapods:ExpoAdapterGoogleSignIn@16.1.5']); function ownLicenseFixture(run,{batch=false}={}){return fixture(({root,file})=>{ - const provenance=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),pins=provenance.knownLicenseSources.filter(value=>value.ownerNpmID&&(batch?!initialOwnComponents.has(value.component):initialOwnComponents.has(value.component))),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),lock={PODS:[], 'EXTERNAL SOURCES':{}}; + const provenance=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),pins=provenance.knownLicenseSources.filter(value=>value.ownerNpmID&&value.component!=='cocoapods:Yoga@0.0.0'&&(batch?!initialOwnComponents.has(value.component):initialOwnComponents.has(value.component))),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),lock={PODS:[], 'EXTERNAL SOURCES':{}}; assert.equal(pins.length,batch?97:3);file('package-lock.json',{packages:Object.fromEntries(pins.map(pin=>[pin.packagePath,installed.packages[pin.packagePath]]))});mkdirSync(join(root,'ios/Pods/Target Support Files'),{recursive:true}); for(const pin of pins){const name=pin.component.slice('cocoapods:'.length,-pin.version.length-1);lock.PODS.push(name+' ('+pin.version+')');lock['EXTERNAL SOURCES'][name]={[':'+pin.sourceKind]:'../'+(pin.sourceKind==='path'?dirname(pin.podspecSource):pin.podspecSource)};for(const path of [pin.packageJSONSource,pin.podspecSource,pin.source])file(path,readFileSync(new URL('../'+path,import.meta.url)));file('ios/Pods/'+name+'/source.m','Synthetic alternate Pod root for source-form negatives');if(pin.sourceKind==='podspec')for(const arch of ['ios-arm64','ios-arm64_x86_64-simulator'])file('ios/Pods/'+name+'/'+name+'.xcframework/'+arch+'/'+name+'.framework/Info.plist','Synthetic framework source inventory');} file('ios/Podfile.lock',lock);return run({root,file,pins,lock,provenance,capture:()=>podNoticeInputs(root).components}); @@ -31,7 +32,6 @@ test('97 explicit RN and Expo own-package bindings resolve exact selected forms for(const pin of pins){const item=items.find(value=>value.id===pin.component),bytes=readFileSync(new URL('../'+pin.source,import.meta.url));assert.ok(item);assert.equal(item.nativeInputs['cocoapods:'+item.name].ownLicense.ownerNpmID,pin.ownerNpmID);assert.ok(item.notices.some(notice=>notice.source===pin.source&¬ice.sha256===pin.sha256&&Buffer.from(notice.body).equals(bytes)));assert.ok(!JSON.stringify(item).includes(root));} const publicInput=unreviewedOSSNoticeInputs(items,provenance);assert.equal(publicInput.components.length,97);assert.equal(publicInput.complete,false);assert.equal(resolveNoticeCoverage(items,provenance).status,'INCOMPLETE'); const vendored=items.filter(item=>item.vendors.length);assert.equal(vendored.length,6);for(const item of vendored)assert.ok(resolveNoticeCoverage([item],approval(item)).unresolved.some(value=>value.reason==='missing-vendored-notice')); - for(const component of ['cocoapods:ExpoRouter@57.0.28','cocoapods:ExpoUI@57.0.25','cocoapods:Yoga@0.0.0'])assert.ok(!provenance.knownLicenseSources.some(value=>value.component===component)); },{batch:true})); test('the explicit batch rejects changed spec, root, form, package version and licence bytes; unknown siblings gain no binding',()=>ownLicenseFixture(({file,pins,lock,capture})=>{ @@ -338,7 +338,7 @@ test('the pinned RN selector uses actual autolinking metadata, with app config a })); -test('three pending Pod selectors expose only actual finite forms/owner matches/hashes and grant no own-text binding',()=>fixture(({root,file})=>{ +test('three Pod selectors expose only actual finite forms/owner matches/hashes and grant no attribution alone',()=>fixture(({root,file})=>{ const targets=[['ExpoRouter','57.0.28','expo-router','57.0.28','ios/ExpoRouter.podspec'],['ExpoUI','57.0.25','@expo/ui','57.0.25','ios/ExpoUI.podspec'],['Yoga','0.0.0','react-native','0.86.3','ReactCommon/yoga/Yoga.podspec']],versions=new Map(),external={}; for(const [name,version,npm,ownerVersion,spec] of targets){versions.set(name,version);file('node_modules/'+npm+'/package.json',{name:npm,version:ownerVersion});file('node_modules/'+npm+'/'+spec,'Public synthetic spec');external[name]={':path':'../node_modules/'+npm+'/'+dirname(spec)};} const select=(values=external,current=versions)=>podActualSelectors(root,{'EXTERNAL SOURCES':values},current,'a'.repeat(64)); @@ -347,3 +347,45 @@ test('three pending Pod selectors expose only actual finite forms/owner matches/ const newer=new Map(versions);newer.set('Yoga','UNKNOWN_PRIVATE_VERSION');const wrongVersion=select(external,newer).at(-1);assert.equal(wrongVersion.resolvedVersionMatches,false);assert.equal(wrongVersion.selectedPodspecSHA256,null);assert.ok(!JSON.stringify(wrongVersion).includes('UNKNOWN')); file('node_modules/react-native/package.json',{name:'PRIVATE_FOREIGN_OWNER',version:'0.86.3'});const foreign=select().at(-1);assert.equal(foreign.canonicalOwnerMatches,false);assert.equal(foreign.ownerPackageJSONSHA256,null);assert.equal(foreign.selectedPodspecSHA256,null);assert.ok(!JSON.stringify(foreign).includes('PRIVATE')); })); + +function selectedOwnPodFixture(run){return fixture(({root,file})=>{ + const provenance=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),ids=['cocoapods:ExpoRouter@57.0.28','cocoapods:ExpoUI@57.0.25','cocoapods:Yoga@0.0.0'],pins=provenance.knownLicenseSources.filter(pin=>ids.includes(pin.component)),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),lock={PODS:[],'EXTERNAL SOURCES':{}}; + assert.equal(pins.length,3);file('package-lock.json',{packages:Object.fromEntries(pins.map(pin=>{const own=pin.ownText??pin;return [own.packagePath,installed.packages[own.packagePath]];}))});mkdirSync(join(root,'ios/Pods/Target Support Files'),{recursive:true}); + for(const pin of pins){const own=pin.ownText??pin,pod=own.pod??pin,name=pin.component.slice(10,-pin.version.length-1);lock.PODS.push(name+' ('+pin.version+')');lock['EXTERNAL SOURCES'][name]={':path':'../'+dirname(pod.podspecSource)};for(const path of [own.packageJSONSource,pod.podspecSource,pin.source])file(path,readFileSync(new URL('../'+path,import.meta.url)));file('ios/Pods/'+name+'/source.m','Synthetic alternate Pod root');} + file('ios/Podfile.lock',lock); + const native=()=>podNoticeInputs(root),capture=(ledger=provenance)=>addKnownOwnNoticeFiles(root,native().components,ledger); + return run({root,file,pins,lock,provenance,native,capture}); +});} + +test('three explicit Pod attributions bind live selectors and verbatim own text without granting coverage',()=>selectedOwnPodFixture(({root,pins,provenance,native,capture})=>{ + const raw=native(),items=capture();assert.equal(provenance.knownLicenseSources.length,113);assert.deepEqual(provenance.components,[]); + for(const pin of pins){const item=items.find(value=>value.id===pin.component),selector=item.nativeInputs['cocoapods:'+item.name].actualSelector;assert.deepEqual(selector,raw.actualSelectors.find(value=>value.component===item.id));assert.equal(selector.podLockSHA256,raw.inputHashes.podLock);assert.equal(selector.kind,'path');assert.ok(item.notices.some(value=>value.source===pin.source&&value.sha256===pin.sha256&&Buffer.from(value.body).equals(readFileSync(join(root,pin.source)))));if(pin.ownText)assert.deepEqual(item.nativeInputs.ownAttribution.pod,pin.ownText.pod);else assert.equal(item.nativeInputs['cocoapods:'+item.name].ownLicense.ownerNpmID,pin.ownerNpmID);} + const review=unreviewedOSSNoticeInputs(items,provenance);assert.equal(review.components.length,3);assert.equal(review.complete,false);assert.ok(!JSON.stringify(review).includes(root));assert.equal(resolveNoticeCoverage(items,provenance).status,'INCOMPLETE'); +})); + +test('Pod own attribution rejects actual source form/root, owner, lock, spec and text drift on every capture',()=>selectedOwnPodFixture(({root,file,pins,lock,provenance,capture})=>{ + const mutate=(path,body)=>{const original=readFileSync(join(root,path));file(path,body);assert.throws(capture);file(path,original);}; + for(const pin of pins){const own=pin.ownText??pin,pod=own.pod??pin,name=pin.component.slice(10,-pin.version.length-1); + for(const path of [own.packageJSONSource,pod.podspecSource,pin.source])mutate(path,Buffer.concat([readFileSync(join(root,path)),Buffer.from('PRIVATE_CHANGED_BYTES')])); + const packageJSON=JSON.parse(readFileSync(join(root,own.packageJSONSource)));for(const delta of [{name:'PRIVATE_OTHER_OWNER'},{version:'999.0.0'}])mutate(own.packageJSONSource,JSON.stringify({...packageJSON,...delta})); + const npmLock=JSON.parse(readFileSync(join(root,'package-lock.json')));for(const field of ['version','resolved','integrity'])mutate('package-lock.json',JSON.stringify({...npmLock,packages:{...npmLock.packages,[own.packagePath]:{...npmLock.packages[own.packagePath],[field]:'PRIVATE_LOCK'}}})); + const alternate=own.packagePath+'/alternate';file(alternate+'/source.m','Synthetic adjacent source'); + for(const selected of [{':podspec':'../'+pod.podspecSource},{':path':'../'+alternate},{...lock['EXTERNAL SOURCES'][name],':git':'PRIVATE_SELECTOR'}]){file('ios/Podfile.lock',{...lock,'EXTERNAL SOURCES':{...lock['EXTERNAL SOURCES'],[name]:selected}});assert.throws(capture);}file('ios/Podfile.lock',lock); + if(pin.ownText){for(const change of [p=>p.ownText.pod.sourceKind='podspec',p=>p.ownText.pod.podspecSHA256='a'.repeat(64),p=>p.ownText.ownerNpmID='npm:PRIVATE@1']){const changed=structuredClone(provenance);change(changed.knownLicenseSources.find(value=>value.component===pin.component));assert.throws(()=>capture(changed));}} + } +})); + +test('unknown Pod siblings/versions gain no attribution and own text cannot clear a vendor boundary',()=>selectedOwnPodFixture(({file,pins,lock,provenance,capture})=>{ + const pin=pins.find(value=>value.ownText),own=pin.ownText,name=pin.component.slice(10,-pin.version.length-1),source=dirname(own.pod.podspecSource),sibling='UnreviewedExpoSibling';file('ios/Podfile.lock',{...lock,PODS:[...lock.PODS,sibling+' ('+pin.version+')'],'EXTERNAL SOURCES':{...lock['EXTERNAL SOURCES'],[sibling]:{':path':'../'+source}}});const unknown=capture().find(value=>value.name===sibling);assert.deepEqual(unknown.notices,[]);assert.equal(unknown.nativeInputs.ownAttribution,undefined);assert.equal(unknown.nativeInputs['cocoapods:'+sibling].actualSelector,undefined); + file('ios/Podfile.lock',{...lock,PODS:lock.PODS.map(value=>value===name+' ('+pin.version+')'?name+' (999.0.0)':value)});const newer=capture().find(value=>value.name===name);assert.deepEqual(newer.notices,[]);assert.equal(newer.nativeInputs.ownAttribution,undefined);file('ios/Podfile.lock',lock); + file(source+'/Separate.xcframework/Info.plist','Synthetic separate vendor');const item=capture().find(value=>value.name===name);assert.equal(item.notices.length,1);assert.equal(resolveNoticeCoverage([item],approval(item)).unresolved[0].reason,'missing-vendored-notice');assert.deepEqual(provenance.components,[]); +})); + +test('live Pod-lock selector provenance is fingerprinted and seal reinspection rejects drift',()=>selectedOwnPodFixture(({root,file,lock,provenance,capture})=>{ + const git=(...args)=>execFileSync('git',['-c','core.hooksPath=/dev/null','-c','commit.gpgsign=false',...args],{cwd:root,stdio:['ignore','pipe','pipe']}); + file('.gitignore','node_modules/\nbuild/\nios/Pods/\nios/Podfile.lock\n');file('package.json',{name:'synthetic-pod-notices',version:'1'});file('app.json',{expo:{plugins:[['expo-asset',{assets:['./assets/notices/third_party_notices.json']}]]}});file('scripts/font-icon-provenance.json',{notices:{},platforms:{},files:{}});const entry=file('index.ts','export const app=1;\n'),payload='assets/notices/third_party_notices.json';file(payload,'[]'); + const items=capture(),approved={...provenance,components:items.map(item=>approval(item).components[0])};file('scripts/app-notice-provenance.json',approved);const recapture=()=>captureResolvedInputs(root,'ios',[entry,join(root,payload)]),coverage=recapture().coverage;assert.equal(coverage.status,'READY');file(payload,JSON.stringify(coverage.sections));git('init','--quiet');git('add','.');git('-c','user.name=Notice test','-c','user.email=notice-test@example.invalid','commit','--quiet','-m','Synthetic Pod notice source'); + const initial=recapture(),seal=createNoticeSeal(root,'ios',initial);assert.equal(seal.complete,true);assert.equal(seal.inputHashes.podLock,initial.native.inputHashes.podLock);assert.ok(!JSON.stringify(seal).includes(root)); + file('ios/Podfile.lock',{...lock,COCOAPODS:'Synthetic changed lock input'});const drift=recapture();assert.notEqual(drift.native.inputHashes.podLock,initial.native.inputHashes.podLock);assert.equal(drift.coverage.status,'INCOMPLETE');assert.ok(drift.coverage.unresolved.every(value=>value.reason==='native-source-digest-mismatch'));assert.throws(()=>createNoticeSeal(root,'ios',drift)); + file('ios/Podfile.lock',lock);assert.deepEqual(createNoticeSeal(root,'ios',recapture()),seal);assert.deepEqual(provenance.components,[]); +})); From 36e06b8bac7042b63b0bb98eccc101d03574633b Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 14:01:40 +0800 Subject: [PATCH 33/37] Review five exact iOS npm notice attributions --- docs/app-notices.md | 22 ++- scripts/app-notice-provenance.json | 217 +++++++++++++++++++++++++- scripts/app-notices.test.mjs | 31 +++- scripts/native-notice-inputs.test.mjs | 4 +- 4 files changed, 264 insertions(+), 10 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index 396269e6..baf93516 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -1,8 +1,9 @@ # App notice capture and final-artifact gate This is incomplete build preparation. The source JSON currently contains the -reviewed font/icon notices only. An empty component ledger deliberately prevents -sealing. Source tests, an internal simulator build, and finding licence filenames +reviewed font/icon notices only. Five exact iOS npm inputs have attribution +reviews; remaining component review and the incomplete payload prevent sealing. +Source tests, an internal simulator build, and finding licence filenames do not establish complete app-library notice coverage or store readiness. The own-package source records additionally bind 97 selected RN/Expo Pods to @@ -12,7 +13,18 @@ package licence. The 91 literal directory/6 exact podspec forms use the existing strict binding helper. These records preserve own-package attribution only. The additional six npm and three Pod own-text records below do not approve component coverage. Metro prelude and every vendor/generated boundary remain -independently unresolved. The component ledger and offline payload are unchanged. +independently unresolved. The offline payload is unchanged. + +The first component batch is iOS-only: `@react-native/assets-registry`, +`js-polyfills`, `normalize-colors` and `virtualized-lists` at 0.86.3, plus +`qrcode-generator` at 2.0.4. Their exact captured `ownAttribution` fingerprints +and original MIT notices match discovery 43682437095 at source82cede4. All 20 selected +JS files matched installed bytes and exact SHA512-pinned cached official tar +members. None has a selected generated source, vendor boundary or extra native +input. Against that inventory, 198 components plus one generated source remain +unreviewed, with all 109 recorded vendor boundaries still requiring their +applicable review. These five records grant no Android, vendor, generated, +native-binary or retained-resource approval; no new discovery or sealing is implied. ## Stable offline resource @@ -260,7 +272,7 @@ the app imports the MaterialCommunityIcons TTF, whose generated asset registry require is intercepted by the pinned resolver into the third. Source-only tests exercise those actual provider functions in memory without Metro or dynamic environment values. These are technical generated-source mappings, not reviewed notice -coverage; the component ledger remains empty. Other virtual IDs are not accepted +coverage; the five npm reviews do not approve their providers. Other virtual IDs are not accepted by a NUL/path wildcard. Final native files not identified from resolved AARs require an exact platform/path `packagedNative` mapping with covered `components` and a pinned `review` reference. Packaged-native mappings remain empty until reviewed discovery. @@ -299,7 +311,7 @@ and absolute roots stay private; only logical package/version identities and hashes enter reports. Unknown roots, changed pins and escaping links reject. All output bytes are fingerprinted, and each input owner remains a separate vendor boundary needing explicit notice review; admitting a generated source -does not inherit a producer's licence or complete the empty approval ledger. +does not inherit a producer's licence or complete component review. Only the actual Gradle build-directory output, configured CMake staging path and Git metadata are excluded. The metadata exporter reads AGP's CMake configuration; its module-local `.cxx` default applies only when CMake is enabled. Canonical diff --git a/scripts/app-notice-provenance.json b/scripts/app-notice-provenance.json index c0949590..cddf8306 100644 --- a/scripts/app-notice-provenance.json +++ b/scripts/app-notice-provenance.json @@ -1,7 +1,222 @@ { "schema": 1, "scope": "INCOMPLETE preparation only. Collect actual Metro/native inputs and commit reviewed platform-union text and mappings before sealing. Empty mappings deliberately fail strict verification; discovery never writes a release payload.", - "components": [], + "components": [ + { + "platform": "ios", + "component": "npm:@react-native/assets-registry@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "ownAttribution": { + "kind": "pinned-upstream-own-attribution", + "ownerNpmID": "npm:@react-native/assets-registry@0.86.3", + "packagePath": "node_modules/@react-native/assets-registry", + "packageJSON": { + "source": "node_modules/@react-native/assets-registry/package.json", + "sha256": "89393bee843633fe8043019288736a8067382922c8e8bd3880ab23662ee72971" + }, + "tarball": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.3.tgz", + "integrity": "sha512-TDhgCZA4wjJg84d5A9swiOQYPIWSKEEVdg9IwMFZDupQzW/F3QoLUrfAJOcalgqTDA9/buTB8awhE3Whwg6u9Q==", + "publisherDeclaration": { + "gitHead": "4829a675c80e64f7c170b0f30d24b3808b7663d3", + "metadataURL": "https://registry.npmjs.org/%40react-native%2Fassets-registry/0.86.3", + "metadataSHA256": "e23123fa9493f8114e30e1ebad2f39eccebf401ce7d8f6dc396a0aa3fc46ee1d" + }, + "selectedSources": [ + { + "source": "node_modules/@react-native/assets-registry/registry.js", + "sha256": "4edff3064f1d79e8a0dd866b9163e13fbb48bec4d3a832ad7673b076d799bd7c" + } + ], + "text": { + "source": "assets/notices/sources/react-native-MIT.txt", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://raw.githubusercontent.com/react/react-native/4829a675c80e64f7c170b0f30d24b3808b7663d3/LICENSE" + } + } + }, + "notices": [ + { + "source": "assets/notices/sources/react-native-MIT.txt", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://raw.githubusercontent.com/react/react-native/4829a675c80e64f7c170b0f30d24b3808b7663d3/LICENSE", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "npm:@react-native/js-polyfills@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "ownAttribution": { + "kind": "pinned-upstream-own-attribution", + "ownerNpmID": "npm:@react-native/js-polyfills@0.86.3", + "packagePath": "node_modules/@react-native/js-polyfills", + "packageJSON": { + "source": "node_modules/@react-native/js-polyfills/package.json", + "sha256": "86ff9ce670176a1f603e2b119f36893a7d824455f02ad639030ea05f8c6aaf33" + }, + "tarball": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.86.3.tgz", + "integrity": "sha512-eYIJ0es967+tePBFQDnl/gidVFxLns3fnbiK6rxscQrGodvuUO6hwxpQnfNynJ8MjbbndImXihXjcnJdc7SzJg==", + "publisherDeclaration": { + "gitHead": "4829a675c80e64f7c170b0f30d24b3808b7663d3", + "metadataURL": "https://registry.npmjs.org/%40react-native%2Fjs-polyfills/0.86.3", + "metadataSHA256": "05cd0c253463e680b4ce3d6e8f6726690be460a1da59ef30370a0dafbea7614d" + }, + "selectedSources": [ + { + "source": "node_modules/@react-native/js-polyfills/index.js", + "sha256": "da03cd1a85b6e7018fabba50ed243292947e041a56b44bfe56cc07e1e6fd7944" + } + ], + "text": { + "source": "assets/notices/sources/react-native-MIT.txt", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://raw.githubusercontent.com/react/react-native/4829a675c80e64f7c170b0f30d24b3808b7663d3/LICENSE" + } + } + }, + "notices": [ + { + "source": "assets/notices/sources/react-native-MIT.txt", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://raw.githubusercontent.com/react/react-native/4829a675c80e64f7c170b0f30d24b3808b7663d3/LICENSE", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "npm:@react-native/normalize-colors@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "ownAttribution": { + "kind": "pinned-upstream-own-attribution", + "ownerNpmID": "npm:@react-native/normalize-colors@0.86.3", + "packagePath": "node_modules/@react-native/normalize-colors", + "packageJSON": { + "source": "node_modules/@react-native/normalize-colors/package.json", + "sha256": "37b0b78d9f920a0adab84199667e388a06605acfbdc45c0a8f7562504b2bade1" + }, + "tarball": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.86.3.tgz", + "integrity": "sha512-Cv3CDkprb67GrzuaS9BGbBJC/6G4lIw3nyKOHRKTqTTum4bn37y5+R0Z04L8mcbQN85eEohNrRwb7IOM4j6uvg==", + "publisherDeclaration": { + "gitHead": "4829a675c80e64f7c170b0f30d24b3808b7663d3", + "metadataURL": "https://registry.npmjs.org/%40react-native%2Fnormalize-colors/0.86.3", + "metadataSHA256": "d1ebc04b795d05c61c928d5017e415079e1f251dd798f7c68f1c52c2d69533d8" + }, + "selectedSources": [ + { + "source": "node_modules/@react-native/normalize-colors/index.js", + "sha256": "2eef8bd869a6c11f2945ed9398aaeaaba6c9373b15e80a05ea43301a47d3847a" + } + ], + "text": { + "source": "assets/notices/sources/react-native-MIT.txt", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://raw.githubusercontent.com/react/react-native/4829a675c80e64f7c170b0f30d24b3808b7663d3/LICENSE" + } + } + }, + "notices": [ + { + "source": "assets/notices/sources/react-native-MIT.txt", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://raw.githubusercontent.com/react/react-native/4829a675c80e64f7c170b0f30d24b3808b7663d3/LICENSE", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "npm:@react-native/virtualized-lists@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "ownAttribution": { + "kind": "pinned-upstream-own-attribution", + "ownerNpmID": "npm:@react-native/virtualized-lists@0.86.3", + "packagePath": "node_modules/@react-native/virtualized-lists", + "packageJSON": { + "source": "node_modules/@react-native/virtualized-lists/package.json", + "sha256": "454a1a9ded25f68ae9962db2929d8a5c08050a2346983c5354901bbfa22b4852" + }, + "tarball": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.86.3.tgz", + "integrity": "sha512-1j44NEyNn05Ut40vHAmoSWbsIcybFkMAOBTwQt1PrESyfSS+qBoyU1LGIogNva0VIa0rQyEC5PzbA7R4/7Nhyw==", + "publisherDeclaration": { + "gitHead": "4829a675c80e64f7c170b0f30d24b3808b7663d3", + "metadataURL": "https://registry.npmjs.org/%40react-native%2Fvirtualized-lists/0.86.3", + "metadataSHA256": "08c644e80b6fd0ef9cc8b962e564bf57a4ffcc3096bdd62628ad508eb719e60d" + }, + "selectedSources": [ + { + "source": "node_modules/@react-native/virtualized-lists/index.js", + "sha256": "8504b9c94898d7a8de8b20971a26232cc8287dee1a290954b2e302a881d7e69b" + } + ], + "text": { + "source": "assets/notices/sources/react-native-MIT.txt", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://raw.githubusercontent.com/react/react-native/4829a675c80e64f7c170b0f30d24b3808b7663d3/LICENSE" + } + } + }, + "notices": [ + { + "source": "assets/notices/sources/react-native-MIT.txt", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://raw.githubusercontent.com/react/react-native/4829a675c80e64f7c170b0f30d24b3808b7663d3/LICENSE", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "npm:qrcode-generator@2.0.4", + "reviewedVendors": [], + "nativeInputs": { + "ownAttribution": { + "kind": "pinned-upstream-own-attribution", + "ownerNpmID": "npm:qrcode-generator@2.0.4", + "packagePath": "node_modules/qrcode-generator", + "packageJSON": { + "source": "node_modules/qrcode-generator/package.json", + "sha256": "0c8db18ad7f1b9dece93dd87dc0624be938f9b9b21e93c0f78e6e2d194ac10e9" + }, + "tarball": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "publisherDeclaration": { + "gitHead": "83b7e8fe3fddd3b0368dbafd6ce56995bd25e3c8", + "metadataURL": "https://registry.npmjs.org/qrcode-generator/2.0.4", + "metadataSHA256": "57bc334458e43210fde1afcb26dec1a4403c0374d1918a7751d83d58a5800792" + }, + "selectedSources": [ + { + "source": "node_modules/qrcode-generator/dist/qrcode.js", + "sha256": "79ec86f82856005b1c887905cfccfcfbec3821ca61c7fd5a952faa5f778f791c" + }, + { + "source": "node_modules/qrcode-generator/dist/qrcode.mjs", + "sha256": "ea91d7118a5395289170da848b7c6758b996163bfbccf312591ab65a4911b7c0" + } + ], + "text": { + "source": "assets/notices/sources/qrcode-generator-MIT.txt", + "sha256": "3a850fa5f08101db6f40676c2786e10bd2cd5fff7b12ffdf1e0c434d4e49d90c", + "upstream": "https://raw.githubusercontent.com/kazuhikoarase/qrcode-generator/83b7e8fe3fddd3b0368dbafd6ce56995bd25e3c8/LICENSE" + } + } + }, + "notices": [ + { + "source": "assets/notices/sources/qrcode-generator-MIT.txt", + "sha256": "3a850fa5f08101db6f40676c2786e10bd2cd5fff7b12ffdf1e0c434d4e49d90c" + } + ], + "upstream": "https://raw.githubusercontent.com/kazuhikoarase/qrcode-generator/83b7e8fe3fddd3b0368dbafd6ce56995bd25e3c8/LICENSE", + "sectionSHA256": "3a850fa5f08101db6f40676c2786e10bd2cd5fff7b12ffdf1e0c434d4e49d90c" + } + ], "generatedSources": { "\u0000polyfill:external-require": { "provider": "node_modules/expo/node_modules/@expo/cli/build/src/start/server/metro/withMetroMultiPlatform.js", diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs index f835a0ae..e8683550 100644 --- a/scripts/app-notices.test.mjs +++ b/scripts/app-notices.test.mjs @@ -261,7 +261,7 @@ test('actual discovery setup trap keeps original failures and writes finite phas test('the explicit Expo virtual IDs resolve only to their pinned installed provider',()=>{ assert.equal(digest('\0polyfill:external-require'),'f32cbb35a430d4c6ee0f555a934acc8f6a83aff8e2b19c414f6d756479b8f03e'); - const provenance=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))); + const provenance={...JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),components:[]}; for(const id of ['\0polyfill:external-require','\0polyfill:environment-variables','\0polyfill:assets-registry']){ const result=capturedSource(process.cwd(),id,provenance); assert.deepEqual(result,{path:id,generated:true,provider:'node_modules/expo/node_modules/@expo/cli/build/src/start/server/metro/withMetroMultiPlatform.js',sha256:'50012292a38cb679ca3f692c5a462de57bebe0f69d5c862e937141a3fc54343f',component:'npm:@expo/cli@57.0.26'}); @@ -337,7 +337,7 @@ test('actual pinned native polyfills and this app font asset route prove the two }); function knownOwnTextFixture(run){return fixture(({root,file})=>{ - const provenance=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),pins=provenance.knownLicenseSources.filter(value=>value.ownText&&value.ecosystem==='npm'),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),paths=[]; + const provenance={...JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),components:[]},pins=provenance.knownLicenseSources.filter(value=>value.ownText&&value.ecosystem==='npm'),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),paths=[]; assert.equal(pins.length,6);file('package-lock.json',{packages:Object.fromEntries(pins.map(pin=>[pin.ownText.packagePath,installed.packages[pin.ownText.packagePath]]))}); for(const pin of pins){for(const source of [pin.ownText.packageJSONSource,...pin.ownText.selectedSources.map(value=>value.source),pin.source])file(source,readFileSync(new URL('../'+source,import.meta.url)));paths.push(join(root,pin.ownText.selectedSources[0].source));} const raw=()=>npmNoticeInputs(root,paths),capture=(ledger=provenance)=>addKnownOwnNoticeFiles(root,raw(),ledger); @@ -371,3 +371,30 @@ test('injected own text preserves discovered NOTICEs and cannot satisfy vendored const pin=pins[0],source=file(pin.ownText.packagePath+'/vendor/separate/index.js','vendored source');file(pin.ownText.packagePath+'/NOTICE','Separate preserved NOTICE'); const original=npmNoticeInputs(root,[source])[0],item=addKnownOwnNoticeFiles(root,[original],provenance)[0];assert.ok(item.notices.some(value=>value.source===original.notices[0].source&&value.sha256===original.notices[0].sha256));assert.deepEqual(item.vendorNotices,original.vendorNotices);assert.equal(resolveNoticeCoverage([item],{schema:1,components:[review(item)]}).unresolved[0].reason,'missing-vendored-notice'); })); + +const reviewedIosMITComponents=['npm:@react-native/assets-registry@0.86.3','npm:@react-native/js-polyfills@0.86.3','npm:@react-native/normalize-colors@0.86.3','npm:@react-native/virtualized-lists@0.86.3','npm:qrcode-generator@2.0.4']; +test('five exact iOS MIT reviews qualify only their actual captured owning provenance and original bodies',()=>knownOwnTextFixture(({root,capture})=>{ + const approved=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),items=capture(),coverage=resolveNoticeCoverage(items,approved); + assert.deepEqual(approved.components.map(value=>value.component),reviewedIosMITComponents);assert.ok(approved.components.every(value=>value.platform==='ios')); + assert.equal(coverage.status,'INCOMPLETE');assert.deepEqual(coverage.unresolved,[{component:'npm:@expo/ui@57.0.25',reason:'unreviewed-component'}]);assert.equal(coverage.sections.length,5); + for(const record of approved.components){const item=items.find(value=>value.id===record.component),section=coverage.sections.find(value=>value.id==='library-'+digest('ios:'+item.id).slice(0,24));assert.deepEqual(item.nativeInputs,record.nativeInputs);assert.ok(Buffer.from(section.body).equals(readFileSync(join(root,item.notices[0].source))));assert.equal(digest(section.body),record.sectionSHA256);assert.equal(section.iosOnly,true);assert.equal(section.androidOnly,undefined);} +})); + +test('the five reviews reject owning-source/notice/native drift, vendors and inferred sibling or Android approval',()=>knownOwnTextFixture(({capture})=>{ + const approved=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),items=capture().filter(value=>reviewedIosMITComponents.includes(value.id)); + const reject=(item,change,reason)=>{const changed=structuredClone(item);change(changed);assert.equal(resolveNoticeCoverage([changed],approved).unresolved[0].reason,reason);}; + for(const item of items){ + reject(item,value=>value.nativeInputs.ownAttribution.packageJSON.sha256='f'.repeat(64),'native-source-digest-mismatch'); + reject(item,value=>value.notices[0].sha256='f'.repeat(64),'notice-set-or-digest-mismatch'); + reject(item,value=>value.nativeInputs.unreviewedNativeInput={sha256:'f'.repeat(64)},'native-source-digest-mismatch'); + reject(item,value=>value.vendors.push('Unreviewed.xcframework'),'unreviewed-vendored-source'); + reject(item,value=>{value.id+='-unknown-sibling';},'unreviewed-component'); + reject(item,value=>{value.id=value.id.slice(0,value.id.lastIndexOf('@')+1)+'999';value.version='999';},'unreviewed-component'); + } + assert.equal(resolveNoticeCoverage(items,approved,'android').unresolved.length,5);assert.ok(resolveNoticeCoverage(items,approved,'android').unresolved.every(value=>value.reason==='unreviewed-component')); +})); + +test('five component reviews cannot seal the unchanged font-only payload',()=>knownOwnTextFixture(({capture})=>{ + const approved=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),items=capture().filter(value=>reviewedIosMITComponents.includes(value.id)),coverage=resolveNoticeCoverage(items,approved),payload=readFileSync(new URL('../assets/notices/third_party_notices.json',import.meta.url)),fontProvenance=JSON.parse(readFileSync(new URL('./font-icon-provenance.json',import.meta.url))); + assert.equal(coverage.status,'READY');assert.throws(()=>assertSourceNoticePayload(payload,coverage,approved,fontProvenance),/Actual included library differs from the committed union/); +})); diff --git a/scripts/native-notice-inputs.test.mjs b/scripts/native-notice-inputs.test.mjs index 15bacba6..40ec3096 100644 --- a/scripts/native-notice-inputs.test.mjs +++ b/scripts/native-notice-inputs.test.mjs @@ -21,7 +21,7 @@ function fixture(run){ } const initialOwnComponents=new Set(['cocoapods:ExpoAppleAuthentication@57.0.2','cocoapods:ExpoFileSystem@57.0.7','cocoapods:ExpoAdapterGoogleSignIn@16.1.5']); function ownLicenseFixture(run,{batch=false}={}){return fixture(({root,file})=>{ - const provenance=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),pins=provenance.knownLicenseSources.filter(value=>value.ownerNpmID&&value.component!=='cocoapods:Yoga@0.0.0'&&(batch?!initialOwnComponents.has(value.component):initialOwnComponents.has(value.component))),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),lock={PODS:[], 'EXTERNAL SOURCES':{}}; + const provenance={...JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),components:[]},pins=provenance.knownLicenseSources.filter(value=>value.ownerNpmID&&value.component!=='cocoapods:Yoga@0.0.0'&&(batch?!initialOwnComponents.has(value.component):initialOwnComponents.has(value.component))),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),lock={PODS:[], 'EXTERNAL SOURCES':{}}; assert.equal(pins.length,batch?97:3);file('package-lock.json',{packages:Object.fromEntries(pins.map(pin=>[pin.packagePath,installed.packages[pin.packagePath]]))});mkdirSync(join(root,'ios/Pods/Target Support Files'),{recursive:true}); for(const pin of pins){const name=pin.component.slice('cocoapods:'.length,-pin.version.length-1);lock.PODS.push(name+' ('+pin.version+')');lock['EXTERNAL SOURCES'][name]={[':'+pin.sourceKind]:'../'+(pin.sourceKind==='path'?dirname(pin.podspecSource):pin.podspecSource)};for(const path of [pin.packageJSONSource,pin.podspecSource,pin.source])file(path,readFileSync(new URL('../'+path,import.meta.url)));file('ios/Pods/'+name+'/source.m','Synthetic alternate Pod root for source-form negatives');if(pin.sourceKind==='podspec')for(const arch of ['ios-arm64','ios-arm64_x86_64-simulator'])file('ios/Pods/'+name+'/'+name+'.xcframework/'+arch+'/'+name+'.framework/Info.plist','Synthetic framework source inventory');} file('ios/Podfile.lock',lock);return run({root,file,pins,lock,provenance,capture:()=>podNoticeInputs(root).components}); @@ -349,7 +349,7 @@ test('three Pod selectors expose only actual finite forms/owner matches/hashes a })); function selectedOwnPodFixture(run){return fixture(({root,file})=>{ - const provenance=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),ids=['cocoapods:ExpoRouter@57.0.28','cocoapods:ExpoUI@57.0.25','cocoapods:Yoga@0.0.0'],pins=provenance.knownLicenseSources.filter(pin=>ids.includes(pin.component)),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),lock={PODS:[],'EXTERNAL SOURCES':{}}; + const provenance={...JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),components:[]},ids=['cocoapods:ExpoRouter@57.0.28','cocoapods:ExpoUI@57.0.25','cocoapods:Yoga@0.0.0'],pins=provenance.knownLicenseSources.filter(pin=>ids.includes(pin.component)),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),lock={PODS:[],'EXTERNAL SOURCES':{}}; assert.equal(pins.length,3);file('package-lock.json',{packages:Object.fromEntries(pins.map(pin=>{const own=pin.ownText??pin;return [own.packagePath,installed.packages[own.packagePath]];}))});mkdirSync(join(root,'ios/Pods/Target Support Files'),{recursive:true}); for(const pin of pins){const own=pin.ownText??pin,pod=own.pod??pin,name=pin.component.slice(10,-pin.version.length-1);lock.PODS.push(name+' ('+pin.version+')');lock['EXTERNAL SOURCES'][name]={':path':'../'+dirname(pod.podspecSource)};for(const path of [own.packageJSONSource,pod.podspecSource,pin.source])file(path,readFileSync(new URL('../'+path,import.meta.url)));file('ios/Pods/'+name+'/source.m','Synthetic alternate Pod root');} file('ios/Podfile.lock',lock); From 112686f96a0f6aceeee3aa6a2fce17eeb5ccf679 Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 14:19:27 +0800 Subject: [PATCH 34/37] Review explicit iOS runtime and FileSystem notice records --- docs/app-notices.md | 27 +- scripts/app-notice-provenance.json | 418 ++++++++++++++++++++++++++ scripts/app-notices.test.mjs | 44 ++- scripts/native-notice-inputs.test.mjs | 3 +- 4 files changed, 482 insertions(+), 10 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index baf93516..fdbfbab5 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -1,7 +1,7 @@ # App notice capture and final-artifact gate This is incomplete build preparation. The source JSON currently contains the -reviewed font/icon notices only. Five exact iOS npm inputs have attribution +reviewed font/icon notices only. Twenty-one exact iOS inputs have attribution reviews; remaining component review and the incomplete payload prevent sealing. Source tests, an internal simulator build, and finding licence filenames do not establish complete app-library notice coverage or store readiness. @@ -21,10 +21,25 @@ The first component batch is iOS-only: `@react-native/assets-registry`, and original MIT notices match discovery 43682437095 at source82cede4. All 20 selected JS files matched installed bytes and exact SHA512-pinned cached official tar members. None has a selected generated source, vendor boundary or extra native -input. Against that inventory, 198 components plus one generated source remain -unreviewed, with all 109 recorded vendor boundaries still requiring their -applicable review. These five records grant no Android, vendor, generated, -native-binary or retained-resource approval; no new discovery or sealing is implied. +input. These five records grant no Android, vendor, generated, native-binary or +retained-resource approval. + +The next fifteen explicit npm records cover the listed React/runtime/polyfill +support packages, with original package LICENSE files and exact registry +tarball/SRI public-source pins. All 63 selected files matched the qualified +capture, installed bytes and official cached tar members. Root-agent source +review read the twelve new distinct MIT texts; React/scheduler reuse the exact +previously reviewed Meta text. A package's MIT label alone grants no approval. +Hoist's BSD text remains deferred; no generic MIT or sibling allowlist is used. + +One additional iOS record covers only `ExpoFileSystem@57.0.7` and its three exact +own-product xcframework/slice boundaries, mapped to that package's original +LICENSE. The selected 63-file source/product fingerprint and published inputs +are pinned. External React/Hermes/framework dependencies gain no attribution. +Against the qualified82 inventory, 182 components plus one generated source +remain unreviewed. Of 109 recorded vendor boundaries, only these three specific +FileSystem boundaries are reviewed; the other 106 remain held. No new discovery, +Android, packaged-native, retained-resource, payload or sealing approval is implied. ## Stable offline resource @@ -272,7 +287,7 @@ the app imports the MaterialCommunityIcons TTF, whose generated asset registry require is intercepted by the pinned resolver into the third. Source-only tests exercise those actual provider functions in memory without Metro or dynamic environment values. These are technical generated-source mappings, not reviewed notice -coverage; the five npm reviews do not approve their providers. Other virtual IDs are not accepted +coverage; component reviews do not approve their providers. Other virtual IDs are not accepted by a NUL/path wildcard. Final native files not identified from resolved AARs require an exact platform/path `packagedNative` mapping with covered `components` and a pinned `review` reference. Packaged-native mappings remain empty until reviewed discovery. diff --git a/scripts/app-notice-provenance.json b/scripts/app-notice-provenance.json index cddf8306..ebb371e3 100644 --- a/scripts/app-notice-provenance.json +++ b/scripts/app-notice-provenance.json @@ -215,6 +215,274 @@ ], "upstream": "https://raw.githubusercontent.com/kazuhikoarase/qrcode-generator/83b7e8fe3fddd3b0368dbafd6ce56995bd25e3c8/LICENSE", "sectionSHA256": "3a850fa5f08101db6f40676c2786e10bd2cd5fff7b12ffdf1e0c434d4e49d90c" + }, + { + "platform": "ios", + "component": "npm:@babel/runtime@7.29.7", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/@babel/runtime/LICENSE", + "sha256": "117da2af0d4ce0fe1c8e19b5cff9dcd806adf973d328d27b11d4448c4ff24f76" + } + ], + "upstream": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "sectionSHA256": "117da2af0d4ce0fe1c8e19b5cff9dcd806adf973d328d27b11d4448c4ff24f76" + }, + { + "platform": "ios", + "component": "npm:abort-controller@3.0.0", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/abort-controller/LICENSE", + "sha256": "dbbf03317f0101fed5643cd87ca80fb769e358fa4f428445bf2315deeb23a154" + } + ], + "upstream": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "sectionSHA256": "dbbf03317f0101fed5643cd87ca80fb769e358fa4f428445bf2315deeb23a154" + }, + { + "platform": "ios", + "component": "npm:base64-js@1.5.1", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/base64-js/LICENSE", + "sha256": "5b37224c080cdcc97c871ada971c224e9926370fe74f11b539aa1cf9f3b1aca1" + } + ], + "upstream": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "sectionSHA256": "5b37224c080cdcc97c871ada971c224e9926370fe74f11b539aa1cf9f3b1aca1" + }, + { + "platform": "ios", + "component": "npm:event-target-shim@5.0.1", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/event-target-shim/LICENSE", + "sha256": "97d87142793695cd73243de7258694abe85b3b6060fca7f247b0a2afa8219678" + } + ], + "upstream": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "sectionSHA256": "97d87142793695cd73243de7258694abe85b3b6060fca7f247b0a2afa8219678" + }, + { + "platform": "ios", + "component": "npm:flow-enums-runtime@0.0.6", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/flow-enums-runtime/LICENSE", + "sha256": "52412d7bc7ce4157ea628bbaacb8829e0a9cb3c58f57f99176126bc8cf2bfc85" + } + ], + "upstream": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", + "sectionSHA256": "52412d7bc7ce4157ea628bbaacb8829e0a9cb3c58f57f99176126bc8cf2bfc85" + }, + { + "platform": "ios", + "component": "npm:invariant@2.2.4", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/invariant/LICENSE", + "sha256": "f657f99d3fb9647db92628e96007aabb46e5f04f33e49999075aab8e250ca7ce" + } + ], + "upstream": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "sectionSHA256": "f657f99d3fb9647db92628e96007aabb46e5f04f33e49999075aab8e250ca7ce" + }, + { + "platform": "ios", + "component": "npm:memoize-one@5.2.1", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/memoize-one/LICENSE", + "sha256": "b1c501095de70182748cb8e8d97ac5577701a4ef3b755ba30d59fd87b24bdb0f" + } + ], + "upstream": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "sectionSHA256": "b1c501095de70182748cb8e8d97ac5577701a4ef3b755ba30d59fd87b24bdb0f" + }, + { + "platform": "ios", + "component": "npm:nullthrows@1.1.1", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/nullthrows/LICENSE", + "sha256": "dd5f97aa25431e25fea6654f12f4ce167dc19b3148ab515cc271aa8efe6eb0be" + } + ], + "upstream": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "sectionSHA256": "dd5f97aa25431e25fea6654f12f4ce167dc19b3148ab515cc271aa8efe6eb0be" + }, + { + "platform": "ios", + "component": "npm:promise@8.3.0", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/promise/LICENSE", + "sha256": "5e733eb92ed0c571da3f27c5da203e0a9f975b4f96e7692c9fa3660ca54820ec" + } + ], + "upstream": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "sectionSHA256": "5e733eb92ed0c571da3f27c5da203e0a9f975b4f96e7692c9fa3660ca54820ec" + }, + { + "platform": "ios", + "component": "npm:react-is@16.13.1", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/hoist-non-react-statics/node_modules/react-is/LICENSE", + "sha256": "52412d7bc7ce4157ea628bbaacb8829e0a9cb3c58f57f99176126bc8cf2bfc85" + } + ], + "upstream": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "sectionSHA256": "52412d7bc7ce4157ea628bbaacb8829e0a9cb3c58f57f99176126bc8cf2bfc85" + }, + { + "platform": "ios", + "component": "npm:react@19.2.3", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/react/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "npm:regenerator-runtime@0.13.11", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/regenerator-runtime/LICENSE", + "sha256": "51887a3d47051ac2fce1210562e5b9fe0830a8a8fabeb272c2d586eeb18a05fd" + } + ], + "upstream": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "sectionSHA256": "51887a3d47051ac2fce1210562e5b9fe0830a8a8fabeb272c2d586eeb18a05fd" + }, + { + "platform": "ios", + "component": "npm:scheduler@0.27.0", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/scheduler/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "npm:stacktrace-parser@0.1.11", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/stacktrace-parser/LICENSE", + "sha256": "e1898814043cb5500483d9c8517edda824b352f696d35e5043fcfb537c221531" + } + ], + "upstream": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "sectionSHA256": "e1898814043cb5500483d9c8517edda824b352f696d35e5043fcfb537c221531" + }, + { + "platform": "ios", + "component": "npm:whatwg-fetch@3.6.20", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/whatwg-fetch/LICENSE", + "sha256": "c887d1c97797646dbb216925ab44a93c6b3e7f03bbe6ef6e0f92b8b8ade63014" + } + ], + "upstream": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "sectionSHA256": "c887d1c97797646dbb216925ab44a93c6b3e7f03bbe6ef6e0f92b8b8ade63014" + }, + { + "platform": "ios", + "component": "cocoapods:ExpoFileSystem@57.0.7", + "reviewedVendors": [ + "ExpoFileSystem.xcframework", + "ExpoFileSystem.xcframework/ios-arm64/ExpoFileSystem.framework", + "ExpoFileSystem.xcframework/ios-arm64_x86_64-simulator/ExpoFileSystem.framework" + ], + "vendorNoticeSources": { + "ExpoFileSystem.xcframework": [ + "node_modules/expo-file-system/LICENSE" + ], + "ExpoFileSystem.xcframework/ios-arm64/ExpoFileSystem.framework": [ + "node_modules/expo-file-system/LICENSE" + ], + "ExpoFileSystem.xcframework/ios-arm64_x86_64-simulator/ExpoFileSystem.framework": [ + "node_modules/expo-file-system/LICENSE" + ] + }, + "nativeInputs": { + "cocoapods:ExpoFileSystem": { + "sha256": "2170187d5f6c7e19a06acf677791826a325457b75e4f15541f452b605edc646e", + "files": 63, + "excludedBuildPaths": [], + "frameworkInputList": { + "source": "ios/Pods/Target Support Files/ExpoFileSystem/ExpoFileSystem-xcframeworks-input-files.xcfilelist", + "sha256": "cbfe1f9e30b8eb3d6f709186ace6be7ec8c1427b1bd18825f4411bbf4b7ca1f7" + }, + "ownLicense": { + "ownerNpmID": "npm:expo-file-system@57.0.7", + "packagePath": "node_modules/expo-file-system", + "sourceKind": "podspec", + "packageJSON": { + "source": "node_modules/expo-file-system/package.json", + "sha256": "db1d8ced7ce04e7daf4e55c3fd543bfbe42e32aa5e192bbb8ae4bda98de50069" + }, + "podspec": { + "source": "node_modules/expo-file-system/ios/ExpoFileSystem.podspec", + "sha256": "c651c016d967b778b8ccd4dea4490454a10ef6c27488d0f05ee9896ca45172f7" + }, + "license": { + "source": "node_modules/expo-file-system/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + "upstream": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz", + "upstreamIntegrity": "sha512-1vkZRadOsIputFDBcclqBa1pO3iXOO6iIsdqJipQWw4V5TSnWgmRTuOF7ro8IzrVue+PHbum++KCG3Tawem4cg==" + } + } + }, + "notices": [ + { + "source": "node_modules/expo-file-system/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" } ], "generatedSources": { @@ -2011,6 +2279,156 @@ "sourceKind": "path", "podspecSource": "node_modules/react-native/ReactCommon/yoga/Yoga.podspec", "podspecSHA256": "64eb7b752d3d6eb02514d5763220453dbc3a6a0aa040f27614a4ba6371b4ba9f" + }, + { + "component": "npm:@babel/runtime@7.29.7", + "ecosystem": "npm", + "version": "7.29.7", + "public": true, + "source": "node_modules/@babel/runtime/LICENSE", + "sha256": "117da2af0d4ce0fe1c8e19b5cff9dcd806adf973d328d27b11d4448c4ff24f76", + "upstream": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "upstreamIntegrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==" + }, + { + "component": "npm:abort-controller@3.0.0", + "ecosystem": "npm", + "version": "3.0.0", + "public": true, + "source": "node_modules/abort-controller/LICENSE", + "sha256": "dbbf03317f0101fed5643cd87ca80fb769e358fa4f428445bf2315deeb23a154", + "upstream": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "upstreamIntegrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==" + }, + { + "component": "npm:base64-js@1.5.1", + "ecosystem": "npm", + "version": "1.5.1", + "public": true, + "source": "node_modules/base64-js/LICENSE", + "sha256": "5b37224c080cdcc97c871ada971c224e9926370fe74f11b539aa1cf9f3b1aca1", + "upstream": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "upstreamIntegrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + { + "component": "npm:event-target-shim@5.0.1", + "ecosystem": "npm", + "version": "5.0.1", + "public": true, + "source": "node_modules/event-target-shim/LICENSE", + "sha256": "97d87142793695cd73243de7258694abe85b3b6060fca7f247b0a2afa8219678", + "upstream": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "upstreamIntegrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, + { + "component": "npm:flow-enums-runtime@0.0.6", + "ecosystem": "npm", + "version": "0.0.6", + "public": true, + "source": "node_modules/flow-enums-runtime/LICENSE", + "sha256": "52412d7bc7ce4157ea628bbaacb8829e0a9cb3c58f57f99176126bc8cf2bfc85", + "upstream": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", + "upstreamIntegrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==" + }, + { + "component": "npm:invariant@2.2.4", + "ecosystem": "npm", + "version": "2.2.4", + "public": true, + "source": "node_modules/invariant/LICENSE", + "sha256": "f657f99d3fb9647db92628e96007aabb46e5f04f33e49999075aab8e250ca7ce", + "upstream": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "upstreamIntegrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==" + }, + { + "component": "npm:memoize-one@5.2.1", + "ecosystem": "npm", + "version": "5.2.1", + "public": true, + "source": "node_modules/memoize-one/LICENSE", + "sha256": "b1c501095de70182748cb8e8d97ac5577701a4ef3b755ba30d59fd87b24bdb0f", + "upstream": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "upstreamIntegrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, + { + "component": "npm:nullthrows@1.1.1", + "ecosystem": "npm", + "version": "1.1.1", + "public": true, + "source": "node_modules/nullthrows/LICENSE", + "sha256": "dd5f97aa25431e25fea6654f12f4ce167dc19b3148ab515cc271aa8efe6eb0be", + "upstream": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "upstreamIntegrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==" + }, + { + "component": "npm:promise@8.3.0", + "ecosystem": "npm", + "version": "8.3.0", + "public": true, + "source": "node_modules/promise/LICENSE", + "sha256": "5e733eb92ed0c571da3f27c5da203e0a9f975b4f96e7692c9fa3660ca54820ec", + "upstream": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "upstreamIntegrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==" + }, + { + "component": "npm:react-is@16.13.1", + "ecosystem": "npm", + "version": "16.13.1", + "public": true, + "source": "node_modules/hoist-non-react-statics/node_modules/react-is/LICENSE", + "sha256": "52412d7bc7ce4157ea628bbaacb8829e0a9cb3c58f57f99176126bc8cf2bfc85", + "upstream": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "upstreamIntegrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + { + "component": "npm:react@19.2.3", + "ecosystem": "npm", + "version": "19.2.3", + "public": true, + "source": "node_modules/react/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "upstreamIntegrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==" + }, + { + "component": "npm:regenerator-runtime@0.13.11", + "ecosystem": "npm", + "version": "0.13.11", + "public": true, + "source": "node_modules/regenerator-runtime/LICENSE", + "sha256": "51887a3d47051ac2fce1210562e5b9fe0830a8a8fabeb272c2d586eeb18a05fd", + "upstream": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "upstreamIntegrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + }, + { + "component": "npm:scheduler@0.27.0", + "ecosystem": "npm", + "version": "0.27.0", + "public": true, + "source": "node_modules/scheduler/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93", + "upstream": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "upstreamIntegrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + { + "component": "npm:stacktrace-parser@0.1.11", + "ecosystem": "npm", + "version": "0.1.11", + "public": true, + "source": "node_modules/stacktrace-parser/LICENSE", + "sha256": "e1898814043cb5500483d9c8517edda824b352f696d35e5043fcfb537c221531", + "upstream": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "upstreamIntegrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==" + }, + { + "component": "npm:whatwg-fetch@3.6.20", + "ecosystem": "npm", + "version": "3.6.20", + "public": true, + "source": "node_modules/whatwg-fetch/LICENSE", + "sha256": "c887d1c97797646dbb216925ab44a93c6b3e7f03bbe6ef6e0f92b8b8ade63014", + "upstream": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "upstreamIntegrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==" } ], "retainedAndroidResources": { diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs index e8683550..3ac23e91 100644 --- a/scripts/app-notices.test.mjs +++ b/scripts/app-notices.test.mjs @@ -373,15 +373,16 @@ test('injected own text preserves discovered NOTICEs and cannot satisfy vendored })); const reviewedIosMITComponents=['npm:@react-native/assets-registry@0.86.3','npm:@react-native/js-polyfills@0.86.3','npm:@react-native/normalize-colors@0.86.3','npm:@react-native/virtualized-lists@0.86.3','npm:qrcode-generator@2.0.4']; +const fiveReviewedLedger=()=>{const value=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url)));return {...value,components:value.components.filter(record=>reviewedIosMITComponents.includes(record.component))};}; test('five exact iOS MIT reviews qualify only their actual captured owning provenance and original bodies',()=>knownOwnTextFixture(({root,capture})=>{ - const approved=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),items=capture(),coverage=resolveNoticeCoverage(items,approved); + const approved=fiveReviewedLedger(),items=capture(),coverage=resolveNoticeCoverage(items,approved); assert.deepEqual(approved.components.map(value=>value.component),reviewedIosMITComponents);assert.ok(approved.components.every(value=>value.platform==='ios')); assert.equal(coverage.status,'INCOMPLETE');assert.deepEqual(coverage.unresolved,[{component:'npm:@expo/ui@57.0.25',reason:'unreviewed-component'}]);assert.equal(coverage.sections.length,5); for(const record of approved.components){const item=items.find(value=>value.id===record.component),section=coverage.sections.find(value=>value.id==='library-'+digest('ios:'+item.id).slice(0,24));assert.deepEqual(item.nativeInputs,record.nativeInputs);assert.ok(Buffer.from(section.body).equals(readFileSync(join(root,item.notices[0].source))));assert.equal(digest(section.body),record.sectionSHA256);assert.equal(section.iosOnly,true);assert.equal(section.androidOnly,undefined);} })); test('the five reviews reject owning-source/notice/native drift, vendors and inferred sibling or Android approval',()=>knownOwnTextFixture(({capture})=>{ - const approved=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),items=capture().filter(value=>reviewedIosMITComponents.includes(value.id)); + const approved=fiveReviewedLedger(),items=capture().filter(value=>reviewedIosMITComponents.includes(value.id)); const reject=(item,change,reason)=>{const changed=structuredClone(item);change(changed);assert.equal(resolveNoticeCoverage([changed],approved).unresolved[0].reason,reason);}; for(const item of items){ reject(item,value=>value.nativeInputs.ownAttribution.packageJSON.sha256='f'.repeat(64),'native-source-digest-mismatch'); @@ -395,6 +396,43 @@ test('the five reviews reject owning-source/notice/native drift, vendors and inf })); test('five component reviews cannot seal the unchanged font-only payload',()=>knownOwnTextFixture(({capture})=>{ - const approved=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),items=capture().filter(value=>reviewedIosMITComponents.includes(value.id)),coverage=resolveNoticeCoverage(items,approved),payload=readFileSync(new URL('../assets/notices/third_party_notices.json',import.meta.url)),fontProvenance=JSON.parse(readFileSync(new URL('./font-icon-provenance.json',import.meta.url))); + const approved=fiveReviewedLedger(),items=capture().filter(value=>reviewedIosMITComponents.includes(value.id)),coverage=resolveNoticeCoverage(items,approved),payload=readFileSync(new URL('../assets/notices/third_party_notices.json',import.meta.url)),fontProvenance=JSON.parse(readFileSync(new URL('./font-icon-provenance.json',import.meta.url))); assert.equal(coverage.status,'READY');assert.throws(()=>assertSourceNoticePayload(payload,coverage,approved,fontProvenance),/Actual included library differs from the committed union/); })); + +const reviewedRuntimeComponents=['npm:@babel/runtime@7.29.7','npm:abort-controller@3.0.0','npm:base64-js@1.5.1','npm:event-target-shim@5.0.1','npm:flow-enums-runtime@0.0.6','npm:invariant@2.2.4','npm:memoize-one@5.2.1','npm:nullthrows@1.1.1','npm:promise@8.3.0','npm:react-is@16.13.1','npm:react@19.2.3','npm:regenerator-runtime@0.13.11','npm:scheduler@0.27.0','npm:stacktrace-parser@0.1.11','npm:whatwg-fetch@3.6.20']; +function runtimeNoticeFixture(run){return fixture(({root,file})=>{ + const approved=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),pins=approved.knownLicenseSources.filter(pin=>reviewedRuntimeComponents.includes(pin.component)),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),packages={},paths=[]; + for(const pin of pins){const packagePath=pin.source.slice(0,pin.source.lastIndexOf('/'));packages[packagePath]=installed.packages[packagePath];for(const source of [packagePath+'/package.json',pin.source])file(source,readFileSync(new URL('../'+source,import.meta.url)));paths.push(file(packagePath+'/selected.js','export const syntheticSelectedSource=1;\n'));} + file('package-lock.json',{packages});return run({root,file,approved,pins,paths,capture:()=>npmNoticeInputs(root,paths)}); +});} + +test('actual ledger contains only the exact21 iOS reviews and15 exact public runtime text pins',()=>runtimeNoticeFixture(({root,approved,pins,capture})=>{ + assert.deepEqual(approved.components.map(value=>value.component),[...reviewedIosMITComponents,...reviewedRuntimeComponents,'cocoapods:ExpoFileSystem@57.0.7']);assert.ok(approved.components.every(value=>value.platform==='ios'));assert.equal(approved.knownLicenseSources.length,128);assert.deepEqual(pins.map(value=>value.component),reviewedRuntimeComponents); + const items=capture(),coverage=resolveNoticeCoverage(items,approved),publicInput=unreviewedOSSNoticeInputs(items,approved),lock=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))); + assert.equal(items.length,15);assert.equal(coverage.status,'READY');assert.equal(coverage.sections.length,15);assert.equal(publicInput.components.length,15); + for(const pin of pins){const item=items.find(value=>value.id===pin.component),packagePath=pin.source.slice(0,pin.source.lastIndexOf('/'));assert.equal(pin.upstream,lock.packages[packagePath].resolved);assert.equal(pin.upstreamIntegrity,lock.packages[packagePath].integrity);assert.equal(item.notices.length,1);assert.equal(item.notices[0].sha256,pin.sha256);assert.ok(Buffer.from(item.notices[0].body).equals(readFileSync(join(root,pin.source))));assert.equal(approved.components.find(value=>value.component===item.id).sectionSHA256,pin.sha256);assert.deepEqual(item.vendors,[]);assert.deepEqual(item.nativeInputs??{},{});} +})); + +test('runtime reviews keep changed text, additional NOTICE, native inputs, vendors, unknowns and Android unapproved',()=>runtimeNoticeFixture(({root,file,approved,pins,paths,capture})=>{ + const items=capture();for(const item of items){ + const changed=structuredClone(item);changed.nativeInputs={unreviewedNative:'synthetic'};assert.equal(resolveNoticeCoverage([changed],approved).unresolved[0].reason,'native-source-digest-mismatch'); + const sibling={...item,id:item.id+'-unreviewed'};assert.equal(resolveNoticeCoverage([sibling],approved).unresolved[0].reason,'unreviewed-component'); + const version={...item,id:item.id.slice(0,item.id.lastIndexOf('@')+1)+'999',version:'999'};assert.equal(resolveNoticeCoverage([version],approved).unresolved[0].reason,'unreviewed-component'); + } + assert.equal(resolveNoticeCoverage(items,approved,'android').unresolved.length,15); + const pin=pins[0],original=readFileSync(join(root,pin.source));file(pin.source,Buffer.concat([original,Buffer.from('PRIVATE_SYNTHETIC_CHANGED_TEXT')]));let changed=capture();assert.equal(resolveNoticeCoverage(changed,approved).unresolved[0].reason,'notice-set-or-digest-mismatch');assert.ok(!JSON.stringify(unreviewedOSSNoticeInputs(changed,approved)).includes('PRIVATE_SYNTHETIC_CHANGED_TEXT'));file(pin.source,original); + const packagePath=pin.source.slice(0,pin.source.lastIndexOf('/')),notice=file(packagePath+'/NOTICE','A separate original notice');assert.equal(resolveNoticeCoverage(capture(),approved).unresolved[0].reason,'notice-set-or-digest-mismatch');rmSync(notice); + const vendor=file(packagePath+'/vendor/separate/selected.js','Synthetic independently attributed vendor');assert.equal(resolveNoticeCoverage(npmNoticeInputs(root,[vendor]),approved).unresolved[0].reason,'unreviewed-vendored-source'); + const unknown=file('node_modules/unreviewed-sibling/index.js','source');file('node_modules/unreviewed-sibling/package.json',{name:'unreviewed-sibling',version:'1'});file('node_modules/unreviewed-sibling/LICENSE',original);const lock=JSON.parse(readFileSync(join(root,'package-lock.json')));lock.packages['node_modules/unreviewed-sibling']={version:'1'};file('package-lock.json',lock);const unreviewed=npmNoticeInputs(root,[unknown]);assert.equal(resolveNoticeCoverage(unreviewed,approved).unresolved[0].reason,'unreviewed-component');assert.equal(unreviewedOSSNoticeInputs(unreviewed,approved).components.length,0); +})); + +test('ExpoFileSystem review binds only its exact63-file product and three own framework notice mappings',()=>{ + const approved=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),record=approved.components.find(value=>value.component==='cocoapods:ExpoFileSystem@57.0.7'),source='node_modules/expo-file-system/LICENSE',sha256='fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4',body=readFileSync(new URL('../'+source,import.meta.url),'utf8'),vendors=['ExpoFileSystem.xcframework','ExpoFileSystem.xcframework/ios-arm64/ExpoFileSystem.framework','ExpoFileSystem.xcframework/ios-arm64_x86_64-simulator/ExpoFileSystem.framework']; + assert.deepEqual(record.reviewedVendors,vendors);assert.deepEqual(record.vendorNoticeSources,Object.fromEntries(vendors.map(value=>[value,[source]])));assert.equal(record.nativeInputs['cocoapods:ExpoFileSystem'].sha256,'2170187d5f6c7e19a06acf677791826a325457b75e4f15541f452b605edc646e');assert.equal(record.nativeInputs['cocoapods:ExpoFileSystem'].files,63);assert.equal(digest(body),sha256); + const item=nativeNoticeInput({id:record.component,name:'ExpoFileSystem',version:'57.0.7',ecosystem:'cocoapods',vendors,notices:[{source,sha256,body}],nativeInputs:record.nativeInputs});assert.equal(resolveNoticeCoverage([item],approved).status,'READY'); + const changed=structuredClone(item);changed.nativeInputs['cocoapods:ExpoFileSystem'].sha256='f'.repeat(64);assert.equal(resolveNoticeCoverage([changed],approved).unresolved[0].reason,'native-source-digest-mismatch'); + for(const vendor of ['Unreviewed.xcframework','React.xcframework'])assert.equal(resolveNoticeCoverage([{...item,vendors:[...vendors,vendor]}],approved).unresolved[0].reason,'unreviewed-vendored-source'); + const mapping=structuredClone(approved);delete mapping.components.find(value=>value.component===record.component).vendorNoticeSources[vendors[0]];assert.equal(resolveNoticeCoverage([item],mapping).unresolved[0].reason,'missing-vendored-notice'); + assert.equal(resolveNoticeCoverage([{...item,notices:[{source,sha256:'f'.repeat(64),body}]}],approved).unresolved[0].reason,'notice-set-or-digest-mismatch');assert.throws(()=>nativeNoticeInput({...item,notices:[{source,sha256,body:body+'changed'}]}));assert.equal(resolveNoticeCoverage([item],approved,'android').unresolved[0].reason,'unreviewed-component'); +}); diff --git a/scripts/native-notice-inputs.test.mjs b/scripts/native-notice-inputs.test.mjs index 40ec3096..5ffd1e64 100644 --- a/scripts/native-notice-inputs.test.mjs +++ b/scripts/native-notice-inputs.test.mjs @@ -349,7 +349,8 @@ test('three Pod selectors expose only actual finite forms/owner matches/hashes a })); function selectedOwnPodFixture(run){return fixture(({root,file})=>{ - const provenance={...JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),components:[]},ids=['cocoapods:ExpoRouter@57.0.28','cocoapods:ExpoUI@57.0.25','cocoapods:Yoga@0.0.0'],pins=provenance.knownLicenseSources.filter(pin=>ids.includes(pin.component)),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),lock={PODS:[],'EXTERNAL SOURCES':{}}; + const provenance={...JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),components:[]};provenance.knownLicenseSources=provenance.knownLicenseSources.slice(0,113); + const ids=['cocoapods:ExpoRouter@57.0.28','cocoapods:ExpoUI@57.0.25','cocoapods:Yoga@0.0.0'],pins=provenance.knownLicenseSources.filter(pin=>ids.includes(pin.component)),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),lock={PODS:[],'EXTERNAL SOURCES':{}}; assert.equal(pins.length,3);file('package-lock.json',{packages:Object.fromEntries(pins.map(pin=>{const own=pin.ownText??pin;return [own.packagePath,installed.packages[own.packagePath]];}))});mkdirSync(join(root,'ios/Pods/Target Support Files'),{recursive:true}); for(const pin of pins){const own=pin.ownText??pin,pod=own.pod??pin,name=pin.component.slice(10,-pin.version.length-1);lock.PODS.push(name+' ('+pin.version+')');lock['EXTERNAL SOURCES'][name]={':path':'../'+dirname(pod.podspecSource)};for(const path of [own.packageJSONSource,pod.podspecSource,pin.source])file(path,readFileSync(new URL('../'+path,import.meta.url)));file('ios/Pods/'+name+'/source.m','Synthetic alternate Pod root');} file('ios/Podfile.lock',lock); From 9cad245e01faf068d5ccde0a4e07648b00101eba Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 14:40:41 +0800 Subject: [PATCH 35/37] Review three exact Expo own-product notice records --- docs/app-notices.md | 19 +++- scripts/app-notice-provenance.json | 174 +++++++++++++++++++++++++++++ scripts/app-notices.test.mjs | 25 ++++- 3 files changed, 210 insertions(+), 8 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index fdbfbab5..3b2279ed 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -1,7 +1,7 @@ # App notice capture and final-artifact gate This is incomplete build preparation. The source JSON currently contains the -reviewed font/icon notices only. Twenty-one exact iOS inputs have attribution +reviewed font/icon notices only. Twenty-four exact iOS inputs have attribution reviews; remaining component review and the incomplete payload prevent sealing. Source tests, an internal simulator build, and finding licence filenames do not establish complete app-library notice coverage or store readiness. @@ -36,10 +36,19 @@ One additional iOS record covers only `ExpoFileSystem@57.0.7` and its three exac own-product xcframework/slice boundaries, mapped to that package's original LICENSE. The selected 63-file source/product fingerprint and published inputs are pinned. External React/Hermes/framework dependencies gain no attribution. -Against the qualified82 inventory, 182 components plus one generated source -remain unreviewed. Of 109 recorded vendor boundaries, only these three specific -FileSystem boundaries are reviewed; the other 106 remain held. No new discovery, -Android, packaged-native, retained-resource, payload or sealing approval is implied. +Three additional iOS records cover only `ExpoFont@57.0.4`, +`ExpoModulesCore@57.0.18` and `ExpoModulesWorklets@57.0.18`, with nine exact +own-product wrapper/slice mappings to the same owning package's original Expo +MIT text. Exact published npm inputs, six product archives and all 277 selected +public files reproduce the three captured native fingerprints. React, Hermes, +JSI, React Native dependencies and system frameworks are excluded from these +mappings; no generic inheritance is allowed. + +Against the qualified82 inventory, 179 components plus one generated source +remain unreviewed. Of 109 recorded vendor boundaries, only the twelve specific +own-product boundaries above are reviewed; the other 97 remain held. No new +discovery, Android, packaged-native, retained-resource, payload or sealing +approval is implied. ## Stable offline resource diff --git a/scripts/app-notice-provenance.json b/scripts/app-notice-provenance.json index ebb371e3..d07004ec 100644 --- a/scripts/app-notice-provenance.json +++ b/scripts/app-notice-provenance.json @@ -483,6 +483,180 @@ ], "upstream": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz", "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "cocoapods:ExpoFont@57.0.4", + "reviewedVendors": [ + "ExpoFont.xcframework", + "ExpoFont.xcframework/ios-arm64/ExpoFont.framework", + "ExpoFont.xcframework/ios-arm64_x86_64-simulator/ExpoFont.framework" + ], + "vendorNoticeSources": { + "ExpoFont.xcframework": [ + "node_modules/expo-font/LICENSE" + ], + "ExpoFont.xcframework/ios-arm64/ExpoFont.framework": [ + "node_modules/expo-font/LICENSE" + ], + "ExpoFont.xcframework/ios-arm64_x86_64-simulator/ExpoFont.framework": [ + "node_modules/expo-font/LICENSE" + ] + }, + "nativeInputs": { + "cocoapods:ExpoFont": { + "sha256": "1b4c071b9f19595a2fd3f6f9a12b17b3205b97a5a3673805c722a4aaacb35db6", + "files": 35, + "excludedBuildPaths": [], + "frameworkInputList": { + "source": "ios/Pods/Target Support Files/ExpoFont/ExpoFont-xcframeworks-input-files.xcfilelist", + "sha256": "7a8db9b822e4abb811f863608e5559d3f95c095d53580fb303efce59261a2cc3" + }, + "ownLicense": { + "ownerNpmID": "npm:expo-font@57.0.4", + "packagePath": "node_modules/expo-font", + "sourceKind": "podspec", + "packageJSON": { + "source": "node_modules/expo-font/package.json", + "sha256": "ad2dd07d792d6a6f3fd0f374e459356c2fbcc749645175743ab32bb4be56c5f7" + }, + "podspec": { + "source": "node_modules/expo-font/ios/ExpoFont.podspec", + "sha256": "e9b906e51f162670d3364873ba9b0d4d4b281287b2482012f53bd9648172dfa0" + }, + "license": { + "source": "node_modules/expo-font/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + "upstream": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.4.tgz", + "upstreamIntegrity": "sha512-7WOMC2oA6xCfOm8bbofqWn2bIqyWFm7YLEnpc/gA1MJH0Ll0TfT/3+CqRkoW2eNRWMS8sxrz5WukUmSvmNquHA==" + } + } + }, + "notices": [ + { + "source": "node_modules/expo-font/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.4.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "cocoapods:ExpoModulesCore@57.0.18", + "reviewedVendors": [ + "ExpoModulesCore.xcframework", + "ExpoModulesCore.xcframework/ios-arm64/ExpoModulesCore.framework", + "ExpoModulesCore.xcframework/ios-arm64_x86_64-simulator/ExpoModulesCore.framework" + ], + "vendorNoticeSources": { + "ExpoModulesCore.xcframework": [ + "node_modules/expo-modules-core/LICENSE" + ], + "ExpoModulesCore.xcframework/ios-arm64/ExpoModulesCore.framework": [ + "node_modules/expo-modules-core/LICENSE" + ], + "ExpoModulesCore.xcframework/ios-arm64_x86_64-simulator/ExpoModulesCore.framework": [ + "node_modules/expo-modules-core/LICENSE" + ] + }, + "nativeInputs": { + "cocoapods:ExpoModulesCore": { + "sha256": "a822393fbf5a3da4d319e10c55e313a6e5a0c8a93eea610183e923fd6a1f3c10", + "files": 193, + "excludedBuildPaths": [], + "frameworkInputList": { + "source": "ios/Pods/Target Support Files/ExpoModulesCore/ExpoModulesCore-xcframeworks-input-files.xcfilelist", + "sha256": "3e54c8996ab0b54bf4d08f17f5da6613baeedff526fdf3f2a00d84cb8c96a30c" + }, + "ownLicense": { + "ownerNpmID": "npm:expo-modules-core@57.0.18", + "packagePath": "node_modules/expo-modules-core", + "sourceKind": "podspec", + "packageJSON": { + "source": "node_modules/expo-modules-core/package.json", + "sha256": "e2041e04ed5a09e29d9d69eb28b4f216c88c65a8b222760076e7eadb50e8c73b" + }, + "podspec": { + "source": "node_modules/expo-modules-core/ExpoModulesCore.podspec", + "sha256": "b8259e2770ded8730eec301be7ac22f1a8624c85dcd4bfeaeaf01a0bcd0c4887" + }, + "license": { + "source": "node_modules/expo-modules-core/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + "upstream": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.18.tgz", + "upstreamIntegrity": "sha512-ovB7C+GVQTlKAOIsOIHJVVXN9Mn0ljf2SYBS+afrRaGqq+HZEn1D3o+HReIVeH1NjCn/xDBb7I4HjldHs6luZA==" + } + } + }, + "notices": [ + { + "source": "node_modules/expo-modules-core/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.18.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "cocoapods:ExpoModulesWorklets@57.0.18", + "reviewedVendors": [ + "ExpoModulesWorklets.xcframework", + "ExpoModulesWorklets.xcframework/ios-arm64/ExpoModulesWorklets.framework", + "ExpoModulesWorklets.xcframework/ios-arm64_x86_64-simulator/ExpoModulesWorklets.framework" + ], + "vendorNoticeSources": { + "ExpoModulesWorklets.xcframework": [ + "node_modules/expo-modules-core/LICENSE" + ], + "ExpoModulesWorklets.xcframework/ios-arm64/ExpoModulesWorklets.framework": [ + "node_modules/expo-modules-core/LICENSE" + ], + "ExpoModulesWorklets.xcframework/ios-arm64_x86_64-simulator/ExpoModulesWorklets.framework": [ + "node_modules/expo-modules-core/LICENSE" + ] + }, + "nativeInputs": { + "cocoapods:ExpoModulesWorklets": { + "sha256": "c1dfd76bbc396c9bffa4b05cbaee14bb4063ae9898caaf14f1f91faf5cc7c33c", + "files": 49, + "excludedBuildPaths": [], + "frameworkInputList": { + "source": "ios/Pods/Target Support Files/ExpoModulesWorklets/ExpoModulesWorklets-xcframeworks-input-files.xcfilelist", + "sha256": "817db22f09dcd31057032c388f06b742d8b026e151986ee582579c653416e6af" + }, + "ownLicense": { + "ownerNpmID": "npm:expo-modules-core@57.0.18", + "packagePath": "node_modules/expo-modules-core", + "sourceKind": "podspec", + "packageJSON": { + "source": "node_modules/expo-modules-core/package.json", + "sha256": "e2041e04ed5a09e29d9d69eb28b4f216c88c65a8b222760076e7eadb50e8c73b" + }, + "podspec": { + "source": "node_modules/expo-modules-core/ExpoModulesWorklets.podspec", + "sha256": "9e25c99d9193663d944fba43798c3b3c93823d67ebb5f6af7251a007067cc95a" + }, + "license": { + "source": "node_modules/expo-modules-core/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + "upstream": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.18.tgz", + "upstreamIntegrity": "sha512-ovB7C+GVQTlKAOIsOIHJVVXN9Mn0ljf2SYBS+afrRaGqq+HZEn1D3o+HReIVeH1NjCn/xDBb7I4HjldHs6luZA==" + } + } + }, + "notices": [ + { + "source": "node_modules/expo-modules-core/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.18.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" } ], "generatedSources": { diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs index 3ac23e91..c3a39dc6 100644 --- a/scripts/app-notices.test.mjs +++ b/scripts/app-notices.test.mjs @@ -401,13 +401,14 @@ test('five component reviews cannot seal the unchanged font-only payload',()=>kn })); const reviewedRuntimeComponents=['npm:@babel/runtime@7.29.7','npm:abort-controller@3.0.0','npm:base64-js@1.5.1','npm:event-target-shim@5.0.1','npm:flow-enums-runtime@0.0.6','npm:invariant@2.2.4','npm:memoize-one@5.2.1','npm:nullthrows@1.1.1','npm:promise@8.3.0','npm:react-is@16.13.1','npm:react@19.2.3','npm:regenerator-runtime@0.13.11','npm:scheduler@0.27.0','npm:stacktrace-parser@0.1.11','npm:whatwg-fetch@3.6.20']; +const twentyOneReviewedLedger=()=>{const value=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url)));return {...value,components:value.components.slice(0,21)};}; function runtimeNoticeFixture(run){return fixture(({root,file})=>{ - const approved=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),pins=approved.knownLicenseSources.filter(pin=>reviewedRuntimeComponents.includes(pin.component)),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),packages={},paths=[]; + const approved=twentyOneReviewedLedger(),pins=approved.knownLicenseSources.filter(pin=>reviewedRuntimeComponents.includes(pin.component)),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),packages={},paths=[]; for(const pin of pins){const packagePath=pin.source.slice(0,pin.source.lastIndexOf('/'));packages[packagePath]=installed.packages[packagePath];for(const source of [packagePath+'/package.json',pin.source])file(source,readFileSync(new URL('../'+source,import.meta.url)));paths.push(file(packagePath+'/selected.js','export const syntheticSelectedSource=1;\n'));} file('package-lock.json',{packages});return run({root,file,approved,pins,paths,capture:()=>npmNoticeInputs(root,paths)}); });} -test('actual ledger contains only the exact21 iOS reviews and15 exact public runtime text pins',()=>runtimeNoticeFixture(({root,approved,pins,capture})=>{ +test('the explicit21 review fixture preserves exact iOS reviews and15 public runtime text pins',()=>runtimeNoticeFixture(({root,approved,pins,capture})=>{ assert.deepEqual(approved.components.map(value=>value.component),[...reviewedIosMITComponents,...reviewedRuntimeComponents,'cocoapods:ExpoFileSystem@57.0.7']);assert.ok(approved.components.every(value=>value.platform==='ios'));assert.equal(approved.knownLicenseSources.length,128);assert.deepEqual(pins.map(value=>value.component),reviewedRuntimeComponents); const items=capture(),coverage=resolveNoticeCoverage(items,approved),publicInput=unreviewedOSSNoticeInputs(items,approved),lock=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))); assert.equal(items.length,15);assert.equal(coverage.status,'READY');assert.equal(coverage.sections.length,15);assert.equal(publicInput.components.length,15); @@ -428,7 +429,7 @@ test('runtime reviews keep changed text, additional NOTICE, native inputs, vendo })); test('ExpoFileSystem review binds only its exact63-file product and three own framework notice mappings',()=>{ - const approved=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),record=approved.components.find(value=>value.component==='cocoapods:ExpoFileSystem@57.0.7'),source='node_modules/expo-file-system/LICENSE',sha256='fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4',body=readFileSync(new URL('../'+source,import.meta.url),'utf8'),vendors=['ExpoFileSystem.xcframework','ExpoFileSystem.xcframework/ios-arm64/ExpoFileSystem.framework','ExpoFileSystem.xcframework/ios-arm64_x86_64-simulator/ExpoFileSystem.framework']; + const approved=twentyOneReviewedLedger(),record=approved.components.find(value=>value.component==='cocoapods:ExpoFileSystem@57.0.7'),source='node_modules/expo-file-system/LICENSE',sha256='fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4',body=readFileSync(new URL('../'+source,import.meta.url),'utf8'),vendors=['ExpoFileSystem.xcframework','ExpoFileSystem.xcframework/ios-arm64/ExpoFileSystem.framework','ExpoFileSystem.xcframework/ios-arm64_x86_64-simulator/ExpoFileSystem.framework']; assert.deepEqual(record.reviewedVendors,vendors);assert.deepEqual(record.vendorNoticeSources,Object.fromEntries(vendors.map(value=>[value,[source]])));assert.equal(record.nativeInputs['cocoapods:ExpoFileSystem'].sha256,'2170187d5f6c7e19a06acf677791826a325457b75e4f15541f452b605edc646e');assert.equal(record.nativeInputs['cocoapods:ExpoFileSystem'].files,63);assert.equal(digest(body),sha256); const item=nativeNoticeInput({id:record.component,name:'ExpoFileSystem',version:'57.0.7',ecosystem:'cocoapods',vendors,notices:[{source,sha256,body}],nativeInputs:record.nativeInputs});assert.equal(resolveNoticeCoverage([item],approved).status,'READY'); const changed=structuredClone(item);changed.nativeInputs['cocoapods:ExpoFileSystem'].sha256='f'.repeat(64);assert.equal(resolveNoticeCoverage([changed],approved).unresolved[0].reason,'native-source-digest-mismatch'); @@ -436,3 +437,21 @@ test('ExpoFileSystem review binds only its exact63-file product and three own fr const mapping=structuredClone(approved);delete mapping.components.find(value=>value.component===record.component).vendorNoticeSources[vendors[0]];assert.equal(resolveNoticeCoverage([item],mapping).unresolved[0].reason,'missing-vendored-notice'); assert.equal(resolveNoticeCoverage([{...item,notices:[{source,sha256:'f'.repeat(64),body}]}],approved).unresolved[0].reason,'notice-set-or-digest-mismatch');assert.throws(()=>nativeNoticeInput({...item,notices:[{source,sha256,body:body+'changed'}]}));assert.equal(resolveNoticeCoverage([item],approved,'android').unresolved[0].reason,'unreviewed-component'); }); + +const expoOwnProducts=[['ExpoFont','57.0.4','expo-font','1b4c071b9f19595a2fd3f6f9a12b17b3205b97a5a3673805c722a4aaacb35db6',35],['ExpoModulesCore','57.0.18','expo-modules-core','a822393fbf5a3da4d319e10c55e313a6e5a0c8a93eea610183e923fd6a1f3c10',193],['ExpoModulesWorklets','57.0.18','expo-modules-core','c1dfd76bbc396c9bffa4b05cbaee14bb4063ae9898caaf14f1f91faf5cc7c33c',49]]; +test('actual24-record ledger preserves the explicit21 scope and adds only the three Expo own products',()=>{ + const approved=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url)));assert.deepEqual(approved.components.map(value=>value.component),[...reviewedIosMITComponents,...reviewedRuntimeComponents,'cocoapods:ExpoFileSystem@57.0.7',...expoOwnProducts.map(([name,version])=>`cocoapods:${name}@${version}`)]);assert.ok(approved.components.every(value=>value.platform==='ios'));assert.equal(approved.knownLicenseSources.length,128); +}); + +test('three Expo product reviews bind exact native/notice maps and reject extra vendors, drift, unknowns and Android',()=>{ + const approved=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),sha256='fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4'; + for(const [name,version,owner,fingerprint,files] of expoOwnProducts){ + const record=approved.components.find(value=>value.component===`cocoapods:${name}@${version}`),source=`node_modules/${owner}/LICENSE`,body=readFileSync(new URL('../'+source,import.meta.url),'utf8'),vendors=[`${name}.xcframework`,`${name}.xcframework/ios-arm64/${name}.framework`,`${name}.xcframework/ios-arm64_x86_64-simulator/${name}.framework`],key='cocoapods:'+name; + assert.deepEqual(record.reviewedVendors,vendors);assert.deepEqual(record.vendorNoticeSources,Object.fromEntries(vendors.map(value=>[value,[source]])));assert.deepEqual(Object.keys(record.nativeInputs),[key]);assert.equal(record.nativeInputs[key].sha256,fingerprint);assert.equal(record.nativeInputs[key].files,files);assert.equal(record.nativeInputs[key].ownLicense.packagePath,'node_modules/'+owner);assert.equal(digest(body),sha256); + const item=nativeNoticeInput({id:record.component,name,version,ecosystem:'cocoapods',vendors,notices:[{source,sha256,body}],nativeInputs:record.nativeInputs});assert.equal(resolveNoticeCoverage([item],approved).status,'READY');assert.equal(unreviewedOSSNoticeInputs([item],approved).components.length,1); + for(const change of [value=>value[key].sha256='f'.repeat(64),value=>value[key].files++,value=>value[key].frameworkInputList.sha256='f'.repeat(64),value=>value[key].ownLicense.podspec.sha256='f'.repeat(64)]){const changed=structuredClone(item);change(changed.nativeInputs);assert.equal(resolveNoticeCoverage([changed],approved).unresolved[0].reason,'native-source-digest-mismatch');} + for(const vendor of ['Unreviewed.xcframework','React.xcframework','Hermes.framework','JSI.framework'])assert.equal(resolveNoticeCoverage([{...item,vendors:[...vendors,vendor]}],approved).unresolved[0].reason,'unreviewed-vendored-source'); + const mapping=structuredClone(approved);mapping.components.find(value=>value.component===record.component).vendorNoticeSources[vendors[0]]=['node_modules/another-owner/LICENSE'];assert.equal(resolveNoticeCoverage([item],mapping).unresolved[0].reason,'missing-vendored-notice'); + assert.equal(resolveNoticeCoverage([{...item,notices:[{source,sha256:'f'.repeat(64),body}]}],approved).unresolved[0].reason,'notice-set-or-digest-mismatch');assert.throws(()=>nativeNoticeInput({...item,notices:[{source,sha256,body:body+'changed'}]}));assert.equal(resolveNoticeCoverage([{...item,id:'cocoapods:UnreviewedSibling@1'}],approved).unresolved[0].reason,'unreviewed-component');assert.equal(resolveNoticeCoverage([item],approved,'android').unresolved[0].reason,'unreviewed-component'); + } +}); From e638ff7786d05e7d827c863ecff5e5ea57a3613a Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 15:12:17 +0800 Subject: [PATCH 36/37] Review exact Expo JavaScript notice records --- docs/app-notices.md | 16 +- scripts/app-notice-provenance.json | 590 +++++++++++++++++++++++++++++ scripts/app-notices.test.mjs | 32 +- 3 files changed, 630 insertions(+), 8 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index 3b2279ed..32183e95 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -1,7 +1,7 @@ # App notice capture and final-artifact gate This is incomplete build preparation. The source JSON currently contains the -reviewed font/icon notices only. Twenty-four exact iOS inputs have attribution +reviewed font/icon notices only. Forty-nine exact iOS inputs have attribution reviews; remaining component review and the incomplete payload prevent sealing. Source tests, an internal simulator build, and finding licence filenames do not establish complete app-library notice coverage or store readiness. @@ -44,7 +44,19 @@ public files reproduce the three captured native fingerprints. React, Hermes, JSI, React Native dependencies and system frameworks are excluded from these mappings; no generic inheritance is allowed. -Against the qualified82 inventory, 179 components plus one generated source +Twenty-five further exact npm records cover only their captured Expo JavaScript +notice sets. Root-agent review matched all 267 selected non-generated JS files, +package manifests and complete original notices to official cached tar/SRI +inputs and installed bytes. Twenty-four reuse the original Expo MIT text; Speech +Recognition preserves the distinct original 2024 jamsch MIT notice. Same-name +native Pods, vendors and generated inputs remain separate. The 24 new public text pins +name exact component/version/source/hash and official registry tar/SRI. The +existing identical Expo SDK-57 public tuple is reused unchanged; its component +record names the approved official tar, with the fresh SRI proof retained in +source-review evidence. +No generic MIT allowlist or name-based native inheritance is permitted. + +Against the qualified82 inventory, 154 components plus one generated source remain unreviewed. Of 109 recorded vendor boundaries, only the twelve specific own-product boundaries above are reviewed; the other 97 remain held. No new discovery, Android, packaged-native, retained-resource, payload or sealing diff --git a/scripts/app-notice-provenance.json b/scripts/app-notice-provenance.json index d07004ec..f440a110 100644 --- a/scripts/app-notice-provenance.json +++ b/scripts/app-notice-provenance.json @@ -657,6 +657,356 @@ ], "upstream": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.18.tgz", "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-apple-authentication@57.0.2", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-apple-authentication/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-apple-authentication/-/expo-apple-authentication-57.0.2.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-application@57.0.3", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-application/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.3.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-asset@57.0.18", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-asset/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.18.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-audio@57.0.5", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-audio/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-audio/-/expo-audio-57.0.5.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-blur@57.0.3", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-blur/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-blur/-/expo-blur-57.0.3.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-constants@57.0.22", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-constants/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.22.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-crypto@57.0.4", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-crypto/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.4.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-document-picker@57.0.3", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-document-picker/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.3.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-file-system@57.0.7", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-file-system/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-font@57.0.4", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-font/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.4.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-haptics@57.0.3", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-haptics/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-57.0.3.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-image-manipulator@57.0.21", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-image-manipulator/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-57.0.21.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-image-picker@57.0.20", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-image-picker/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.20.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-linking@57.0.13", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-linking/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.13.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-modules-core@57.0.18", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-modules-core/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.18.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-network@57.0.2", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-network/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-network/-/expo-network-57.0.2.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-notifications@57.0.20", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-notifications/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-57.0.20.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-secure-store@57.0.4", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-secure-store/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.4.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-sharing@57.0.21", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-sharing/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-57.0.21.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-speech-recognition@57.0.0", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-speech-recognition/LICENSE", + "sha256": "d3ee89fbcdb9592f396dcfe657aae382152ac50398f1261ff2ea215b7485ddc8" + } + ], + "upstream": "https://registry.npmjs.org/expo-speech-recognition/-/expo-speech-recognition-57.0.0.tgz", + "sectionSHA256": "d3ee89fbcdb9592f396dcfe657aae382152ac50398f1261ff2ea215b7485ddc8" + }, + { + "platform": "ios", + "component": "npm:expo-sqlite@57.0.4", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-sqlite/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-57.0.4.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-status-bar@57.0.1", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-status-bar/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-57.0.1.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-web-browser@57.0.3", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-web-browser/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-57.0.3.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo-widgets@57.0.26", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo-widgets/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo-widgets/-/expo-widgets-57.0.26.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "npm:expo@57.0.24", + "reviewedVendors": [], + "nativeInputs": {}, + "notices": [ + { + "source": "node_modules/expo/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + } + ], + "upstream": "https://registry.npmjs.org/expo/-/expo-57.0.24.tgz", + "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" } ], "generatedSources": { @@ -2603,6 +2953,246 @@ "sha256": "c887d1c97797646dbb216925ab44a93c6b3e7f03bbe6ef6e0f92b8b8ade63014", "upstream": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", "upstreamIntegrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==" + }, + { + "component": "npm:expo-apple-authentication@57.0.2", + "ecosystem": "npm", + "version": "57.0.2", + "public": true, + "source": "node_modules/expo-apple-authentication/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-apple-authentication/-/expo-apple-authentication-57.0.2.tgz", + "upstreamIntegrity": "sha512-00KnlyRceRKski+ljZz8q8JhJZ4D3ZScU+Y0RqS6ElJ8GaCWfd7Qh5tMr47yIU/dKDrOnkzkFUM+6vyHS5HI7Q==" + }, + { + "component": "npm:expo-application@57.0.3", + "ecosystem": "npm", + "version": "57.0.3", + "public": true, + "source": "node_modules/expo-application/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.3.tgz", + "upstreamIntegrity": "sha512-t/CxeLio0NjY9/cOak1tQp62/hAUjzJxmhR4rVUR7K5bb9YoEtfl1ZekTn+Iuh+dYOMYzF0l72ODMSmY3CR1NA==" + }, + { + "component": "npm:expo-asset@57.0.18", + "ecosystem": "npm", + "version": "57.0.18", + "public": true, + "source": "node_modules/expo-asset/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.18.tgz", + "upstreamIntegrity": "sha512-zDIQkUr0NM2S7YRj98Zohf/OBuM1q0o4ctxLz1C1oe4/33lHNLjp+2v3qUtBWVwn0zKka8UGADmS8M+83Wi6pw==" + }, + { + "component": "npm:expo-audio@57.0.5", + "ecosystem": "npm", + "version": "57.0.5", + "public": true, + "source": "node_modules/expo-audio/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-audio/-/expo-audio-57.0.5.tgz", + "upstreamIntegrity": "sha512-39fX5JC5zDvELB4/eDJPj2XAYRN0W9+K2DfDWm5N3aC5WZ7HRrhupWtT6hbH09NlfYpsRSqYOwEZnocFM42ynQ==" + }, + { + "component": "npm:expo-blur@57.0.3", + "ecosystem": "npm", + "version": "57.0.3", + "public": true, + "source": "node_modules/expo-blur/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-blur/-/expo-blur-57.0.3.tgz", + "upstreamIntegrity": "sha512-uyBeJwxIuXzWXXNrnRub5pcF8VzQsiOz0VBTWDpMG2UztmaFyfAz6pI/GCwRtWpHhiOpLoJj79WLdg1GMB+9cg==" + }, + { + "component": "npm:expo-constants@57.0.22", + "ecosystem": "npm", + "version": "57.0.22", + "public": true, + "source": "node_modules/expo-constants/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.22.tgz", + "upstreamIntegrity": "sha512-+mBjnzMWMKIrQa7BP7LTDRC0W7cI+q12/sZns4HzVQJGzgGoAdRJSTEKiObxIw/3EswKXlESkRob8upiL95Q4A==" + }, + { + "component": "npm:expo-crypto@57.0.4", + "ecosystem": "npm", + "version": "57.0.4", + "public": true, + "source": "node_modules/expo-crypto/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.4.tgz", + "upstreamIntegrity": "sha512-LqMMs/N6wvwZyw4deVmcl3AgXgqMwCVQ9WyWf2eaWyrk99TXSKk5da3QspiLOOjnn5Qv9f8C50cDNuoyLThpdw==" + }, + { + "component": "npm:expo-document-picker@57.0.3", + "ecosystem": "npm", + "version": "57.0.3", + "public": true, + "source": "node_modules/expo-document-picker/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.3.tgz", + "upstreamIntegrity": "sha512-XlxdZ0zzv44JRD8O15DISgIO8FTaZ581oc4kQp6MJoR7vdahjcY+2qCuDt5k1J8/lc0YR+bXon/v81norciuxw==" + }, + { + "component": "npm:expo-file-system@57.0.7", + "ecosystem": "npm", + "version": "57.0.7", + "public": true, + "source": "node_modules/expo-file-system/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz", + "upstreamIntegrity": "sha512-1vkZRadOsIputFDBcclqBa1pO3iXOO6iIsdqJipQWw4V5TSnWgmRTuOF7ro8IzrVue+PHbum++KCG3Tawem4cg==" + }, + { + "component": "npm:expo-font@57.0.4", + "ecosystem": "npm", + "version": "57.0.4", + "public": true, + "source": "node_modules/expo-font/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.4.tgz", + "upstreamIntegrity": "sha512-7WOMC2oA6xCfOm8bbofqWn2bIqyWFm7YLEnpc/gA1MJH0Ll0TfT/3+CqRkoW2eNRWMS8sxrz5WukUmSvmNquHA==" + }, + { + "component": "npm:expo-haptics@57.0.3", + "ecosystem": "npm", + "version": "57.0.3", + "public": true, + "source": "node_modules/expo-haptics/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-57.0.3.tgz", + "upstreamIntegrity": "sha512-qucUXtwtJdtECKSQK5D6TQBc/0SxKj7s5wQ4mrfSxhZsHrS9a4rcxINl/gbruMaY9spTXStco32ST4pV0vXmxw==" + }, + { + "component": "npm:expo-image-manipulator@57.0.21", + "ecosystem": "npm", + "version": "57.0.21", + "public": true, + "source": "node_modules/expo-image-manipulator/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-57.0.21.tgz", + "upstreamIntegrity": "sha512-NZeNvZxBNf/PK97Df5zORmtqRR/LovkA+lHdRLm1IJFiVKjs4xr+Ib6/jWhDxn04VOX3TJswCQyu4SCxkgMe4Q==" + }, + { + "component": "npm:expo-image-picker@57.0.20", + "ecosystem": "npm", + "version": "57.0.20", + "public": true, + "source": "node_modules/expo-image-picker/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.20.tgz", + "upstreamIntegrity": "sha512-plgcGuMZMWqDchjXQW6S/WepYLB5KqfbZOzwC6xe0+j5IhY4ipvVuRZcXeP/0NgKgU0Jiu/XoTK4Bi8crtZ+sQ==" + }, + { + "component": "npm:expo-linking@57.0.13", + "ecosystem": "npm", + "version": "57.0.13", + "public": true, + "source": "node_modules/expo-linking/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.13.tgz", + "upstreamIntegrity": "sha512-1cjN7Ely0FZqlZItfPYjl6jjrCFLRevrIE/H5XhA6tgDm9uhTaSyJQXyftgt2FvJZ7liheDft7WMnPB9Uktx+A==" + }, + { + "component": "npm:expo-modules-core@57.0.18", + "ecosystem": "npm", + "version": "57.0.18", + "public": true, + "source": "node_modules/expo-modules-core/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.18.tgz", + "upstreamIntegrity": "sha512-ovB7C+GVQTlKAOIsOIHJVVXN9Mn0ljf2SYBS+afrRaGqq+HZEn1D3o+HReIVeH1NjCn/xDBb7I4HjldHs6luZA==" + }, + { + "component": "npm:expo-network@57.0.2", + "ecosystem": "npm", + "version": "57.0.2", + "public": true, + "source": "node_modules/expo-network/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-network/-/expo-network-57.0.2.tgz", + "upstreamIntegrity": "sha512-BfK1kdftZcM6o9o1FAG7/y0l6vWX0n8td0M5lwkvaMeRXZ9LkRwOi9CQXwvsMTQGQiPwtQXgwiAze13cqjt75g==" + }, + { + "component": "npm:expo-notifications@57.0.20", + "ecosystem": "npm", + "version": "57.0.20", + "public": true, + "source": "node_modules/expo-notifications/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-57.0.20.tgz", + "upstreamIntegrity": "sha512-T1xaydHHlsOt6sA9DlSC74Kt+yQb9vwM79dBcac5U/5GPdU7e80LleDleQbMgUXO4c2W/ZKBCljDONyvOqteXQ==" + }, + { + "component": "npm:expo-secure-store@57.0.4", + "ecosystem": "npm", + "version": "57.0.4", + "public": true, + "source": "node_modules/expo-secure-store/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.4.tgz", + "upstreamIntegrity": "sha512-7zBA1RThURTXdP4w65kBzZNbcSsfArqVd+p+ETAnPRWXO8J296Qd0YaCVyRpJ0HSwlCnGdvOnqO+NgSreHsOhA==" + }, + { + "component": "npm:expo-sharing@57.0.21", + "ecosystem": "npm", + "version": "57.0.21", + "public": true, + "source": "node_modules/expo-sharing/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-57.0.21.tgz", + "upstreamIntegrity": "sha512-7Kv3ai5kdSZ/3Ok05AOWUcXSfDrqeQ5rKpWztIvi86A4WxQCed+DV4AtnhfEwZ0ZeFxJj2Au/mjfQ06J4YK8HA==" + }, + { + "component": "npm:expo-speech-recognition@57.0.0", + "ecosystem": "npm", + "version": "57.0.0", + "public": true, + "source": "node_modules/expo-speech-recognition/LICENSE", + "sha256": "d3ee89fbcdb9592f396dcfe657aae382152ac50398f1261ff2ea215b7485ddc8", + "upstream": "https://registry.npmjs.org/expo-speech-recognition/-/expo-speech-recognition-57.0.0.tgz", + "upstreamIntegrity": "sha512-eUHnlNPhYemXIYi2y/bGigDjefE8y0hySAx+tAbaKwEVStYJxJKPHDYWll0z3ELHFLzdyx22V8qhZic9Q6yhNA==" + }, + { + "component": "npm:expo-sqlite@57.0.4", + "ecosystem": "npm", + "version": "57.0.4", + "public": true, + "source": "node_modules/expo-sqlite/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-57.0.4.tgz", + "upstreamIntegrity": "sha512-3N/bnDx4+kWEC9GABxKPLe/6RHTZJR/7DNT5tz/cYi8f7emign3ooavXnJn0+JPBA0JhHmsdDPrmC+CtT86Jzg==" + }, + { + "component": "npm:expo-status-bar@57.0.1", + "ecosystem": "npm", + "version": "57.0.1", + "public": true, + "source": "node_modules/expo-status-bar/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-57.0.1.tgz", + "upstreamIntegrity": "sha512-Xwaq1gAoVRWx5dPG5VhT5RSbnI9OilhZnO5qoPBnUaBAa5VzRzfdS8q0/bsPt0jR2DKLtGuP0bQ6efMJ4RIMDg==" + }, + { + "component": "npm:expo-web-browser@57.0.3", + "ecosystem": "npm", + "version": "57.0.3", + "public": true, + "source": "node_modules/expo-web-browser/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-57.0.3.tgz", + "upstreamIntegrity": "sha512-+ecvhyS/PdWkpv4ai6FF1Trd6Y/4kn/eQ3O86mie2ecU3w+I8bKZh13kppY/jw1uSL7yikt03L5W/iiHojrFwQ==" + }, + { + "component": "npm:expo-widgets@57.0.26", + "ecosystem": "npm", + "version": "57.0.26", + "public": true, + "source": "node_modules/expo-widgets/LICENSE", + "sha256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4", + "upstream": "https://registry.npmjs.org/expo-widgets/-/expo-widgets-57.0.26.tgz", + "upstreamIntegrity": "sha512-gXVYxNeYR08QvohfnyyqTVxGgsF07PtK0Pg/i4vdFs2akT6mf/m4/vc3Co1F5lMDzbQSTpZyCDMxVOImv3F46g==" } ], "retainedAndroidResources": { diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs index c3a39dc6..bc606d92 100644 --- a/scripts/app-notices.test.mjs +++ b/scripts/app-notices.test.mjs @@ -401,9 +401,9 @@ test('five component reviews cannot seal the unchanged font-only payload',()=>kn })); const reviewedRuntimeComponents=['npm:@babel/runtime@7.29.7','npm:abort-controller@3.0.0','npm:base64-js@1.5.1','npm:event-target-shim@5.0.1','npm:flow-enums-runtime@0.0.6','npm:invariant@2.2.4','npm:memoize-one@5.2.1','npm:nullthrows@1.1.1','npm:promise@8.3.0','npm:react-is@16.13.1','npm:react@19.2.3','npm:regenerator-runtime@0.13.11','npm:scheduler@0.27.0','npm:stacktrace-parser@0.1.11','npm:whatwg-fetch@3.6.20']; -const twentyOneReviewedLedger=()=>{const value=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url)));return {...value,components:value.components.slice(0,21)};}; -function runtimeNoticeFixture(run){return fixture(({root,file})=>{ - const approved=twentyOneReviewedLedger(),pins=approved.knownLicenseSources.filter(pin=>reviewedRuntimeComponents.includes(pin.component)),installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),packages={},paths=[]; +const twentyOneReviewedLedger=()=>{const value=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url)));return {...value,components:value.components.slice(0,21),knownLicenseSources:value.knownLicenseSources.slice(0,128)};}; +function runtimeNoticeFixture(run,approved=twentyOneReviewedLedger(),pins=approved.knownLicenseSources.filter(pin=>reviewedRuntimeComponents.includes(pin.component))){return fixture(({root,file})=>{ + const installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))),packages={},paths=[]; for(const pin of pins){const packagePath=pin.source.slice(0,pin.source.lastIndexOf('/'));packages[packagePath]=installed.packages[packagePath];for(const source of [packagePath+'/package.json',pin.source])file(source,readFileSync(new URL('../'+source,import.meta.url)));paths.push(file(packagePath+'/selected.js','export const syntheticSelectedSource=1;\n'));} file('package-lock.json',{packages});return run({root,file,approved,pins,paths,capture:()=>npmNoticeInputs(root,paths)}); });} @@ -439,12 +439,13 @@ test('ExpoFileSystem review binds only its exact63-file product and three own fr }); const expoOwnProducts=[['ExpoFont','57.0.4','expo-font','1b4c071b9f19595a2fd3f6f9a12b17b3205b97a5a3673805c722a4aaacb35db6',35],['ExpoModulesCore','57.0.18','expo-modules-core','a822393fbf5a3da4d319e10c55e313a6e5a0c8a93eea610183e923fd6a1f3c10',193],['ExpoModulesWorklets','57.0.18','expo-modules-core','c1dfd76bbc396c9bffa4b05cbaee14bb4063ae9898caaf14f1f91faf5cc7c33c',49]]; -test('actual24-record ledger preserves the explicit21 scope and adds only the three Expo own products',()=>{ - const approved=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url)));assert.deepEqual(approved.components.map(value=>value.component),[...reviewedIosMITComponents,...reviewedRuntimeComponents,'cocoapods:ExpoFileSystem@57.0.7',...expoOwnProducts.map(([name,version])=>`cocoapods:${name}@${version}`)]);assert.ok(approved.components.every(value=>value.platform==='ios'));assert.equal(approved.knownLicenseSources.length,128); +const twentyFourReviewedLedger=()=>{const value=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url)));return {...value,components:value.components.slice(0,24),knownLicenseSources:value.knownLicenseSources.slice(0,128)};}; +test('the explicit24 review fixture preserves the21 scope and adds only the three Expo own products',()=>{ + const approved=twentyFourReviewedLedger();assert.deepEqual(approved.components.map(value=>value.component),[...reviewedIosMITComponents,...reviewedRuntimeComponents,'cocoapods:ExpoFileSystem@57.0.7',...expoOwnProducts.map(([name,version])=>`cocoapods:${name}@${version}`)]);assert.ok(approved.components.every(value=>value.platform==='ios'));assert.equal(approved.knownLicenseSources.length,128); }); test('three Expo product reviews bind exact native/notice maps and reject extra vendors, drift, unknowns and Android',()=>{ - const approved=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))),sha256='fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4'; + const approved=twentyFourReviewedLedger(),sha256='fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4'; for(const [name,version,owner,fingerprint,files] of expoOwnProducts){ const record=approved.components.find(value=>value.component===`cocoapods:${name}@${version}`),source=`node_modules/${owner}/LICENSE`,body=readFileSync(new URL('../'+source,import.meta.url),'utf8'),vendors=[`${name}.xcframework`,`${name}.xcframework/ios-arm64/${name}.framework`,`${name}.xcframework/ios-arm64_x86_64-simulator/${name}.framework`],key='cocoapods:'+name; assert.deepEqual(record.reviewedVendors,vendors);assert.deepEqual(record.vendorNoticeSources,Object.fromEntries(vendors.map(value=>[value,[source]])));assert.deepEqual(Object.keys(record.nativeInputs),[key]);assert.equal(record.nativeInputs[key].sha256,fingerprint);assert.equal(record.nativeInputs[key].files,files);assert.equal(record.nativeInputs[key].ownLicense.packagePath,'node_modules/'+owner);assert.equal(digest(body),sha256); @@ -455,3 +456,22 @@ test('three Expo product reviews bind exact native/notice maps and reject extra assert.equal(resolveNoticeCoverage([{...item,notices:[{source,sha256:'f'.repeat(64),body}]}],approved).unresolved[0].reason,'notice-set-or-digest-mismatch');assert.throws(()=>nativeNoticeInput({...item,notices:[{source,sha256,body:body+'changed'}]}));assert.equal(resolveNoticeCoverage([{...item,id:'cocoapods:UnreviewedSibling@1'}],approved).unresolved[0].reason,'unreviewed-component');assert.equal(resolveNoticeCoverage([item],approved,'android').unresolved[0].reason,'unreviewed-component'); } }); + +const reviewedExpoJsComponents=["npm:expo-apple-authentication@57.0.2","npm:expo-application@57.0.3","npm:expo-asset@57.0.18","npm:expo-audio@57.0.5","npm:expo-blur@57.0.3","npm:expo-constants@57.0.22","npm:expo-crypto@57.0.4","npm:expo-document-picker@57.0.3","npm:expo-file-system@57.0.7","npm:expo-font@57.0.4","npm:expo-haptics@57.0.3","npm:expo-image-manipulator@57.0.21","npm:expo-image-picker@57.0.20","npm:expo-linking@57.0.13","npm:expo-modules-core@57.0.18","npm:expo-network@57.0.2","npm:expo-notifications@57.0.20","npm:expo-secure-store@57.0.4","npm:expo-sharing@57.0.21","npm:expo-speech-recognition@57.0.0","npm:expo-sqlite@57.0.4","npm:expo-status-bar@57.0.1","npm:expo-web-browser@57.0.3","npm:expo-widgets@57.0.26","npm:expo@57.0.24"]; + +const expoJsSourceLedger=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))); +const expoJsSourcePins=reviewedExpoJsComponents.map(component=>component==='npm:expo@57.0.24'?expoJsSourceLedger.knownLicenseSources.find(pin=>pin.component===component):expoJsSourceLedger.knownLicenseSources.slice(128).find(pin=>pin.component===component)); +test('actual49-record ledger preserves24 reviews and128 pins, adds24 exact pins and reuses Expo tuple',()=>{ + assert.deepEqual(expoJsSourceLedger.components.map(value=>value.component),[...reviewedIosMITComponents,...reviewedRuntimeComponents,'cocoapods:ExpoFileSystem@57.0.7',...expoOwnProducts.map(([name,version])=>`cocoapods:${name}@${version}`),...reviewedExpoJsComponents]);assert.ok(expoJsSourceLedger.components.every(value=>value.platform==='ios'));assert.equal(expoJsSourceLedger.knownLicenseSources.length,152);assert.deepEqual(expoJsSourceLedger.knownLicenseSources.slice(128).map(pin=>pin.component),reviewedExpoJsComponents.filter(value=>value!=='npm:expo@57.0.24')); + assert.deepEqual(expoJsSourcePins.at(-1),{component:'npm:expo@57.0.24',ecosystem:'npm',version:'57.0.24',public:true,source:'node_modules/expo/LICENSE',sha256:'fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4',upstream:'https://github.com/expo/expo/blob/sdk-57/LICENSE'}); +}); +test('25 Expo JS reviews preserve all original notice bytes and explicit public source qualification',()=>runtimeNoticeFixture(({root,approved,pins,capture})=>{ + const items=capture(),coverage=resolveNoticeCoverage(items,approved),publicInput=unreviewedOSSNoticeInputs(items,approved),lock=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url)));assert.equal(items.length,25);assert.equal(coverage.status,'READY');assert.equal(coverage.sections.length,25);assert.equal(publicInput.components.length,25); + for(const pin of pins){const item=items.find(value=>value.id===pin.component),packagePath=pin.source.slice(0,pin.source.lastIndexOf('/')),record=approved.components.find(value=>value.component===item.id);assert.equal(record.upstream,lock.packages[packagePath].resolved);if(pin.component!=='npm:expo@57.0.24'){assert.equal(pin.upstream,lock.packages[packagePath].resolved);assert.equal(pin.upstreamIntegrity,lock.packages[packagePath].integrity);}assert.deepEqual(item.vendors,[]);assert.deepEqual(item.nativeInputs??{},{});assert.equal(item.notices.length,1);assert.equal(item.notices[0].sha256,pin.sha256);assert.ok(Buffer.from(item.notices[0].body).equals(readFileSync(join(root,pin.source))));assert.equal(record.sectionSHA256,pin.sha256);} +},expoJsSourceLedger,expoJsSourcePins)); +test('Expo JavaScript review cannot infer Pod/native/vendor, unknown, changed text/NOTICE or Android approval',()=>runtimeNoticeFixture(({root,file,approved,pins,capture})=>{ + const items=capture();for(const item of items){const changed=structuredClone(item);changed.nativeInputs={unreviewedNative:true};assert.equal(resolveNoticeCoverage([changed],approved).unresolved[0].reason,'native-source-digest-mismatch');assert.equal(resolveNoticeCoverage([{...item,id:item.id+'-unknown'}],approved).unresolved[0].reason,'unreviewed-component');assert.equal(resolveNoticeCoverage([{...item,id:item.id.slice(0,item.id.lastIndexOf('@')+1)+'999',version:'999'}],approved).unresolved[0].reason,'unreviewed-component');} + assert.equal(resolveNoticeCoverage(items,approved,'android').unresolved.length,25);const font=items.find(value=>value.id==='npm:expo-font@57.0.4'),speech=items.find(value=>value.id==='npm:expo-speech-recognition@57.0.0');assert.equal(resolveNoticeCoverage([{...font,id:'cocoapods:ExpoFont@57.0.4',name:'ExpoFont',ecosystem:'cocoapods'}],approved).unresolved[0].reason,'native-source-digest-mismatch');assert.equal(resolveNoticeCoverage([{...speech,id:'cocoapods:ExpoSpeechRecognition@57.0.0',name:'ExpoSpeechRecognition',ecosystem:'cocoapods'}],approved).unresolved[0].reason,'unreviewed-component'); + const pin=pins.find(value=>value.component===speech.id),body=readFileSync(join(root,pin.source));file(pin.source,Buffer.concat([body,Buffer.from('PRIVATE_UNREVIEWED_NOTICE')]));let changed=capture();assert.equal(resolveNoticeCoverage(changed,approved).unresolved[0].reason,'notice-set-or-digest-mismatch');assert.ok(!JSON.stringify(unreviewedOSSNoticeInputs(changed,approved)).includes('PRIVATE_UNREVIEWED_NOTICE'));file(pin.source,body);const packagePath=pin.source.slice(0,pin.source.lastIndexOf('/')),notice=file(packagePath+'/NOTICE','A separately attributed original notice');assert.equal(resolveNoticeCoverage(capture(),approved).unresolved[0].reason,'notice-set-or-digest-mismatch');rmSync(notice);const vendor=file(packagePath+'/vendor/separate/selected.js','synthetic source');assert.equal(resolveNoticeCoverage(npmNoticeInputs(root,[vendor]),approved).unresolved[0].reason,'unreviewed-vendored-source'); + const unknown=file('node_modules/unreviewed-expo-sibling/index.js','source');file('node_modules/unreviewed-expo-sibling/package.json',{name:'unreviewed-expo-sibling',version:'1',license:'MIT'});file('node_modules/unreviewed-expo-sibling/LICENSE',body);const lock=JSON.parse(readFileSync(join(root,'package-lock.json')));lock.packages['node_modules/unreviewed-expo-sibling']={version:'1'};file('package-lock.json',lock);const unreviewed=npmNoticeInputs(root,[unknown]);assert.equal(resolveNoticeCoverage(unreviewed,approved).unresolved[0].reason,'unreviewed-component');assert.equal(unreviewedOSSNoticeInputs(unreviewed,approved).components.length,0); +},expoJsSourceLedger,expoJsSourcePins)); From 6c189743a2c58f269bf0eecdbe7bbd0606ae2535 Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 15:28:41 +0800 Subject: [PATCH 37/37] Review exact React Native source-Pod notice records --- docs/app-notices.md | 13 +- scripts/app-notice-provenance.json | 975 +++++++++++++++++++++++++++++ scripts/app-notices.test.mjs | 43 +- 3 files changed, 1025 insertions(+), 6 deletions(-) diff --git a/docs/app-notices.md b/docs/app-notices.md index 32183e95..20efc0ce 100644 --- a/docs/app-notices.md +++ b/docs/app-notices.md @@ -1,7 +1,7 @@ # App notice capture and final-artifact gate This is incomplete build preparation. The source JSON currently contains the -reviewed font/icon notices only. Forty-nine exact iOS inputs have attribution +reviewed font/icon notices only. Seventy-four exact iOS inputs have attribution reviews; remaining component review and the incomplete payload prevent sealing. Source tests, an internal simulator build, and finding licence filenames do not establish complete app-library notice coverage or store readiness. @@ -56,7 +56,16 @@ record names the approved official tar, with the fresh SRI proof retained in source-review evidence. No generic MIT allowlist or name-based native inheritance is permitted. -Against the qualified82 inventory, 154 components plus one generated source +Twenty-five exact React Native 0.86.3 source-Pod records cover their selected +`ReactCommon/react` trees. Whole official npm tar/SRI, package, podspec and +original Meta MIT bytes were verified. Every own podspec reads its licence from +the same pinned package JSON. All 357 captured file entries (338 unique files) +match published and installed source; public tree projections and the unchanged +native-source scanner reproduce all 25 qualified82 fingerprints. Existing public +text pins are reused; no new text, native vendor or generated input is approved. +Dependencies and expanded transitive compile/link inputs remain separate. + +Against the qualified82 inventory, 129 components plus one generated source remain unreviewed. Of 109 recorded vendor boundaries, only the twelve specific own-product boundaries above are reviewed; the other 97 remain held. No new discovery, Android, packaged-native, retained-resource, payload or sealing diff --git a/scripts/app-notice-provenance.json b/scripts/app-notice-provenance.json index f440a110..2d47d6f1 100644 --- a/scripts/app-notice-provenance.json +++ b/scripts/app-notice-provenance.json @@ -1007,6 +1007,981 @@ ], "upstream": "https://registry.npmjs.org/expo/-/expo-57.0.24.tgz", "sectionSHA256": "fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4" + }, + { + "platform": "ios", + "component": "cocoapods:React-debug@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-debug": { + "sha256": "461d4fcb13191775cdb8a68a17584bb0b9fd031933e17bdd0821387af3940792", + "files": 15, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/debug/React-debug.podspec", + "sha256": "691efcd70ea5f7f5c06e3a658f4cefaa0e97d1ca0c6127b7932bce5ea6805b36" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-defaultsnativemodule@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-defaultsnativemodule": { + "sha256": "a8ba37bb3e463062881f9c41295879fe0139ceb0e7132b77fa00ae33714cc43a", + "files": 4, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/nativemodule/defaults/React-defaultsnativemodule.podspec", + "sha256": "7d0c637a1860b4b5e95661d6fca3935f2ffdf77c3e4709c1a9945b3a75659c74" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-domnativemodule@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-domnativemodule": { + "sha256": "640db07f26f237e0e3aed3f763d1f329427d4a7ce8ca15d2480a630ccec1822c", + "files": 4, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/nativemodule/dom/React-domnativemodule.podspec", + "sha256": "ac4afb3d18be45b8ede907d32c7f8fc919e504867cf97305187a23cb8b01d308" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-featureflags@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-featureflags": { + "sha256": "a7f85c35ba89c614e8fe6ae651456dbc7a8d6c65c648b75a2c7dd5438748af1c", + "files": 16, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/featureflags/React-featureflags.podspec", + "sha256": "77e7190a785a18caf543c8185d0753d520edb508acb84e90175b0f0aeff20a7e" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-featureflagsnativemodule@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-featureflagsnativemodule": { + "sha256": "38b9664d88dbdbb51aa2483ce1c7598b2ceb90b2da894908a2db653d17c5c3c7", + "files": 4, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/nativemodule/featureflags/React-featureflagsnativemodule.podspec", + "sha256": "f0f05355381411c9f9dfb67daf3e43c38eb10b28de9648cbbb07e0afb9980ed3" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-graphics@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-graphics": { + "sha256": "4996ac850ba41431862f61351739ce4cb8b4149c2e60cf80a15efc4f0f2111cf", + "files": 55, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/renderer/graphics/React-graphics.podspec", + "sha256": "0d8dbdec5e7161ac7ab8b5a11d3300f9a5e76cc9d3dc6c03fc147fdac86f2480" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-idlecallbacksnativemodule@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-idlecallbacksnativemodule": { + "sha256": "62747eda85c808c7d15cc9e9f955d9ade12c69a03dca3d67e7272b506e67518a", + "files": 4, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks/React-idlecallbacksnativemodule.podspec", + "sha256": "8060de6df4bfe3834ac6b5c287ed2a04d6859127b83f45e92b24e898518d6b0d" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-ImageManager@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-ImageManager": { + "sha256": "ee8c22e3be131d31421a0436c3a2c6a99fda2d973e051b5825a6c39d40cb37f5", + "files": 9, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios/React-ImageManager.podspec", + "sha256": "0f0d8f7a2cf7c58d148c2d2e5eb2f99b02dd942a6da4e1e876d465de37210195" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-intersectionobservernativemodule@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-intersectionobservernativemodule": { + "sha256": "8d5b405e8197cfeb4ddbb31a5ea2a6642c2daff78501c34de9b050f749443430", + "files": 4, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/nativemodule/intersectionobserver/React-intersectionobservernativemodule.podspec", + "sha256": "40c167df44c18a79a9c1d0611a9c085826354247c205480d8d717a5f75861dfe" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-microtasksnativemodule@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-microtasksnativemodule": { + "sha256": "aba4c0c9642d06e868dafcdc736a284f967d9fbd7eb8c6192558fac315fb3590", + "files": 4, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/nativemodule/microtasks/React-microtasksnativemodule.podspec", + "sha256": "20ea373d4c882b1ff39afba5cdb1c5d1657b0c081470baa77fc355eace1022b7" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-mutationobservernativemodule@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-mutationobservernativemodule": { + "sha256": "bb84d58726430bdcc04736788b484c70faa377fdbbd16411b159e92a748ddd70", + "files": 4, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver/React-mutationobservernativemodule.podspec", + "sha256": "e556b08b4936bacd20614efee59f54ab259a649d735b7ccb0d353ff983ff1608" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-NativeModulesApple@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-NativeModulesApple": { + "sha256": "87e87837067ed4aa181d7a52e2ac65e3da9e250df74f50affa4eb7a0caeb247e", + "files": 8, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios/React-NativeModulesApple.podspec", + "sha256": "af7d1f0b2aac2b059d9737fa1c01144abbb726d05e949002a2643c09a4cae65d" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-networking@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-networking": { + "sha256": "75bdf8bdd84f336ae58467508a3dd6c950c6628e12cb8a23b4cf45c22bcc22d6", + "files": 5, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/networking/React-networking.podspec", + "sha256": "c746d41d3018a9c1d052a07fb47b592f990b6a2429d716f1340aa6d79f5a3d7e" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-performancecdpmetrics@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-performancecdpmetrics": { + "sha256": "229ca7bac7e57fda680c92cb0fc46038b6890f52e1c0a5b1b287bf3d4df78ce0", + "files": 8, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/performance/cdpmetrics/React-performancecdpmetrics.podspec", + "sha256": "383d30666e276612d6040605e64aa3682b319c52f770b52413520a5d88c33df1" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-performancetimeline@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-performancetimeline": { + "sha256": "608dbcb6669c3f1c05ebd7b48d2a5b0897c0db5308edaea1c13d83aef5784584", + "files": 20, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/performance/timeline/React-performancetimeline.podspec", + "sha256": "96004d39d66e9cb2cf448256fc840e215d92e558f0e527afa8e6ce23ac0c7ffb" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-rendererconsistency@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-rendererconsistency": { + "sha256": "8547f61bbc24c8daa9d5f46ce40f54ac743a112859433a3a03071591a4ee3b4e", + "files": 5, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/renderer/consistency/React-rendererconsistency.podspec", + "sha256": "b37debf97ed80a1cbcb0617111571e314b225ff607af614cd7ca1dd5d805b5a9" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-renderercss@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-renderercss": { + "sha256": "a7abab273b4ae14f8c8ee0d6a4d66afb0c73b54a299d075024c17c6e0751721c", + "files": 48, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/renderer/css/React-renderercss.podspec", + "sha256": "d106d23ba18f67c62d05dad31e7c6c5f6aa6270a901227351d99bb5c50001c78" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-rendererdebug@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-rendererdebug": { + "sha256": "159d99ea38853bf774316621dd834fd76e6d853d414afc454ee378207ee85a1b", + "files": 9, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/renderer/debug/React-rendererdebug.podspec", + "sha256": "e3811e6d2674516c745fb9d3862caf05e774282076922136c9e89f5a2d2c4ec1" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-RuntimeApple@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-RuntimeApple": { + "sha256": "fddb1e9e49a6edf660ca0f9cac842121ff612f996738bdde2910603c6ca0a098", + "files": 19, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/runtime/platform/ios/React-RuntimeApple.podspec", + "sha256": "bceef980337797a2bb696ae01c87c6481c1052cd686536a0c8ba07f69dc60b1d" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-RuntimeCore@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-RuntimeCore": { + "sha256": "4ef291df15b26b73c032e30dd02899bf0c7c34103db377b0200c0bc0e67ca6e0", + "files": 41, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/runtime/React-RuntimeCore.podspec", + "sha256": "2bc4eee8c4e438c162cd8e7326f5a95cbf720771699229dfc290673c1e1460c2" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-runtimescheduler@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-runtimescheduler": { + "sha256": "3e5a4f30c0b760978bf8aa8259b157afc6d4ff3b1d2b7096f7fd21b91df9844e", + "files": 23, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/renderer/runtimescheduler/React-runtimescheduler.podspec", + "sha256": "f8cacf1f410040da40f27995a4eef9dd3a22298f5e94e03bbabb1b2632ff1d50" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-timing@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-timing": { + "sha256": "c82b50cc37cfec9e98ec0023aefc92d818f20a4af7ffa4f7d55c28ac40128bb1", + "files": 5, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/timing/React-timing.podspec", + "sha256": "4675aa157ea8c09e19a9ecbd271367f504cfabf885672b7eb7072ccbed0d6ede" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-utils@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-utils": { + "sha256": "debd2b28a4587bff08d550bc82b3fb0284dbdc05cc938c9968ee64845ee286d4", + "files": 35, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/utils/React-utils.podspec", + "sha256": "b6a8c0d5c4c381a93c296bb034c87343857fa5e57cd3481b8ec3039307e298f3" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-viewtransitionnativemodule@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-viewtransitionnativemodule": { + "sha256": "4a869d961fe6014790efa07f16e3682efadca0178fee684089464427a0e77a3b", + "files": 4, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/nativemodule/viewtransition/React-viewtransitionnativemodule.podspec", + "sha256": "f1e03c3c9877bbf971838dae09442d07d3f231d58503f87a9e7b1c39b09d138d" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + { + "platform": "ios", + "component": "cocoapods:React-webperformancenativemodule@0.86.3", + "reviewedVendors": [], + "nativeInputs": { + "cocoapods:React-webperformancenativemodule": { + "sha256": "ac9e679b9a0c6f51bc1c7d50337e409b8a4c2c6392a5a6b48c97049e84a7d1d5", + "files": 4, + "excludedBuildPaths": [], + "ownLicense": { + "ownerNpmID": "npm:react-native@0.86.3", + "packagePath": "node_modules/react-native", + "sourceKind": "path", + "packageJSON": { + "source": "node_modules/react-native/package.json", + "sha256": "416224a4e7caf2ee1f8f9bead8508d0b608feed745371ce0a8f0e2cafa16d144" + }, + "podspec": { + "source": "node_modules/react-native/ReactCommon/react/nativemodule/webperformance/React-webperformancenativemodule.podspec", + "sha256": "dd436b07c4c1d019a74eaefe50a68c51ae1391cb10ea8545affac1f723f7bcb4" + }, + "license": { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + }, + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "upstreamIntegrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==" + } + } + }, + "notices": [ + { + "source": "node_modules/react-native/LICENSE", + "sha256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" + } + ], + "upstream": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "sectionSHA256": "da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93" } ], "generatedSources": { diff --git a/scripts/app-notices.test.mjs b/scripts/app-notices.test.mjs index bc606d92..f2037b8b 100644 --- a/scripts/app-notices.test.mjs +++ b/scripts/app-notices.test.mjs @@ -1,13 +1,14 @@ import assert from 'node:assert/strict'; import {execFileSync,spawnSync} from 'node:child_process'; -import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,rmSync,realpathSync,chmodSync,symlinkSync} from 'node:fs'; +import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,rmSync,realpathSync,chmodSync,symlinkSync,cpSync,readdirSync} from 'node:fs'; import {tmpdir} from 'node:os'; -import {join} from 'node:path'; +import {join,dirname} from 'node:path'; import {fileURLToPath} from 'node:url'; import test from 'node:test'; import {parse} from 'yaml'; import {addKnownOwnNoticeFiles,npmNoticeInputs,nativeNoticeInput,resolveNoticeCoverage,assertSourceNoticePayload,publicNoticeReport,assertFinalNoticeEvidence,capturedSource,assertCapturedSources,assertFinalSourceMap,assertPackagedNativeCoverage,digest,unreviewedOSSNoticeInputs} from './app-notices.mjs'; import {captureResolvedInputs,createNoticeSeal} from './capture-app-notices.mjs'; +import {podNoticeInputs} from './native-notice-inputs.mjs'; const mit=readFileSync(new URL('../node_modules/react-native/LICENSE',import.meta.url),'utf8'); function fixture(run){ @@ -459,9 +460,9 @@ test('three Expo product reviews bind exact native/notice maps and reject extra const reviewedExpoJsComponents=["npm:expo-apple-authentication@57.0.2","npm:expo-application@57.0.3","npm:expo-asset@57.0.18","npm:expo-audio@57.0.5","npm:expo-blur@57.0.3","npm:expo-constants@57.0.22","npm:expo-crypto@57.0.4","npm:expo-document-picker@57.0.3","npm:expo-file-system@57.0.7","npm:expo-font@57.0.4","npm:expo-haptics@57.0.3","npm:expo-image-manipulator@57.0.21","npm:expo-image-picker@57.0.20","npm:expo-linking@57.0.13","npm:expo-modules-core@57.0.18","npm:expo-network@57.0.2","npm:expo-notifications@57.0.20","npm:expo-secure-store@57.0.4","npm:expo-sharing@57.0.21","npm:expo-speech-recognition@57.0.0","npm:expo-sqlite@57.0.4","npm:expo-status-bar@57.0.1","npm:expo-web-browser@57.0.3","npm:expo-widgets@57.0.26","npm:expo@57.0.24"]; -const expoJsSourceLedger=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))); +const expoJsSourceLedger=(()=>{const value=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url)));return {...value,components:value.components.slice(0,49)};})(); const expoJsSourcePins=reviewedExpoJsComponents.map(component=>component==='npm:expo@57.0.24'?expoJsSourceLedger.knownLicenseSources.find(pin=>pin.component===component):expoJsSourceLedger.knownLicenseSources.slice(128).find(pin=>pin.component===component)); -test('actual49-record ledger preserves24 reviews and128 pins, adds24 exact pins and reuses Expo tuple',()=>{ +test('the explicit49 review fixture preserves24 reviews and128 pins, adds24 exact pins and reuses Expo tuple',()=>{ assert.deepEqual(expoJsSourceLedger.components.map(value=>value.component),[...reviewedIosMITComponents,...reviewedRuntimeComponents,'cocoapods:ExpoFileSystem@57.0.7',...expoOwnProducts.map(([name,version])=>`cocoapods:${name}@${version}`),...reviewedExpoJsComponents]);assert.ok(expoJsSourceLedger.components.every(value=>value.platform==='ios'));assert.equal(expoJsSourceLedger.knownLicenseSources.length,152);assert.deepEqual(expoJsSourceLedger.knownLicenseSources.slice(128).map(pin=>pin.component),reviewedExpoJsComponents.filter(value=>value!=='npm:expo@57.0.24')); assert.deepEqual(expoJsSourcePins.at(-1),{component:'npm:expo@57.0.24',ecosystem:'npm',version:'57.0.24',public:true,source:'node_modules/expo/LICENSE',sha256:'fb3ca4a837f5779e83cef89b78253a8949cfb9429c340309f62d0465ec6610b4',upstream:'https://github.com/expo/expo/blob/sdk-57/LICENSE'}); }); @@ -469,6 +470,40 @@ test('25 Expo JS reviews preserve all original notice bytes and explicit public const items=capture(),coverage=resolveNoticeCoverage(items,approved),publicInput=unreviewedOSSNoticeInputs(items,approved),lock=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url)));assert.equal(items.length,25);assert.equal(coverage.status,'READY');assert.equal(coverage.sections.length,25);assert.equal(publicInput.components.length,25); for(const pin of pins){const item=items.find(value=>value.id===pin.component),packagePath=pin.source.slice(0,pin.source.lastIndexOf('/')),record=approved.components.find(value=>value.component===item.id);assert.equal(record.upstream,lock.packages[packagePath].resolved);if(pin.component!=='npm:expo@57.0.24'){assert.equal(pin.upstream,lock.packages[packagePath].resolved);assert.equal(pin.upstreamIntegrity,lock.packages[packagePath].integrity);}assert.deepEqual(item.vendors,[]);assert.deepEqual(item.nativeInputs??{},{});assert.equal(item.notices.length,1);assert.equal(item.notices[0].sha256,pin.sha256);assert.ok(Buffer.from(item.notices[0].body).equals(readFileSync(join(root,pin.source))));assert.equal(record.sectionSHA256,pin.sha256);} },expoJsSourceLedger,expoJsSourcePins)); + +const reviewedRNSourcePods=['React-debug','React-defaultsnativemodule','React-domnativemodule','React-featureflags','React-featureflagsnativemodule','React-graphics','React-idlecallbacksnativemodule','React-ImageManager','React-intersectionobservernativemodule','React-microtasksnativemodule','React-mutationobservernativemodule','React-NativeModulesApple','React-networking','React-performancecdpmetrics','React-performancetimeline','React-rendererconsistency','React-renderercss','React-rendererdebug','React-RuntimeApple','React-RuntimeCore','React-runtimescheduler','React-timing','React-utils','React-viewtransitionnativemodule','React-webperformancenativemodule']; +const rnSourceLedger=JSON.parse(readFileSync(new URL('./app-notice-provenance.json',import.meta.url))); +const rnSourceRecords=rnSourceLedger.components.slice(49); +function rnSourceFixture(run){return fixture(({root,file})=>{ + const pkg='node_modules/react-native'; + for(const source of new Set([pkg+'/package.json',pkg+'/LICENSE',...rnSourceRecords.map(record=>dirname(record.nativeInputs['cocoapods:'+record.component.split(':')[1].split('@')[0]].ownLicense.podspec.source))])){ + const target=join(root,source);mkdirSync(dirname(target),{recursive:true});cpSync(new URL('../'+source,import.meta.url),target,{recursive:true}); + } + const installed=JSON.parse(readFileSync(new URL('../package-lock.json',import.meta.url))); + file('package-lock.json',{packages:{[pkg]:installed.packages[pkg]}}); + mkdirSync(join(root,'ios/Pods/Target Support Files'),{recursive:true}); + const lock={PODS:reviewedRNSourcePods.map(name=>`${name} (0.86.3)`),'EXTERNAL SOURCES':Object.fromEntries(rnSourceRecords.map(record=>{const name=record.component.split(':')[1].split('@')[0];return [name,{':path':'../'+dirname(record.nativeInputs['cocoapods:'+name].ownLicense.podspec.source)}];}))}; + file('ios/Podfile.lock',lock);return run({root,file,lock,capture:()=>podNoticeInputs(root),approved:rnSourceLedger}); +});} + +test('actual74-record ledger adds only25 exact RN source Pods without new public pins',()=>{ + assert.equal(rnSourceLedger.components.length,74);assert.deepEqual(rnSourceLedger.components.slice(0,49),expoJsSourceLedger.components);assert.equal(rnSourceLedger.knownLicenseSources.length,152); + assert.deepEqual(rnSourceRecords.map(value=>value.component),reviewedRNSourcePods.map(name=>`cocoapods:${name}@0.86.3`));assert.ok(rnSourceLedger.components.every(value=>value.platform==='ios')); + let files=0;for(const record of rnSourceRecords){const name=record.component.split(':')[1].split('@')[0],key='cocoapods:'+name,input=record.nativeInputs[key];assert.deepEqual(Object.keys(record.nativeInputs),[key]);assert.deepEqual(record.reviewedVendors,[]);assert.deepEqual(Object.keys(input),['sha256','files','excludedBuildPaths','ownLicense']);assert.deepEqual(input.excludedBuildPaths,[]);assert.equal(input.ownLicense.ownerNpmID,'npm:react-native@0.86.3');assert.equal(input.ownLicense.sourceKind,'path');assert.ok(input.ownLicense.podspec.source.startsWith('node_modules/react-native/ReactCommon/react/'));assert.deepEqual(record.notices,[{source:'node_modules/react-native/LICENSE',sha256:digest(mit)}]);assert.equal(record.sectionSHA256,digest(mit));files+=input.files;} + assert.equal(files,357); +}); +test('25 actual source-Pod captures match approved native fingerprints and original MIT bytes',()=>rnSourceFixture(({capture,approved})=>{ + const input=capture();assert.deepEqual(input.unresolved,[]);assert.equal(input.components.length,25);const coverage=resolveNoticeCoverage(input.components,approved);assert.equal(coverage.status,'READY');assert.equal(coverage.sections.length,25);assert.equal(unreviewedOSSNoticeInputs(input.components,approved).components.length,25); + for(const item of input.components){const record=approved.components.find(value=>value.component===item.id);assert.deepEqual(item.nativeInputs,record.nativeInputs);assert.deepEqual(item.vendors,[]);assert.deepEqual(item.vendorNotices,{});assert.equal(item.notices.length,1);assert.equal(item.notices[0].body,mit);assert.deepEqual(item.notices.map(({source,sha256})=>({source,sha256})),record.notices);} + assert.throws(()=>assertSourceNoticePayload(Buffer.from(JSON.stringify([font])+'\n'),coverage,fonts)); +})); +test('RN source reviews reject actual source, own-text and selector drift without sibling or Android approval',()=>rnSourceFixture(({root,file,lock,capture,approved})=>{ + const items=capture().components,first=items.find(value=>value.name==='React-debug');assert.equal(resolveNoticeCoverage(items,expoJsSourceLedger).unresolved.length,25);assert.equal(resolveNoticeCoverage(items,approved,'android').unresolved.length,25); + assert.equal(resolveNoticeCoverage([{...first,id:'cocoapods:React-UnreviewedSibling@0.86.3'}],approved).unresolved[0].reason,'unreviewed-component');assert.equal(resolveNoticeCoverage([{...first,id:'cocoapods:React-debug@999',version:'999'}],approved).unresolved[0].reason,'unreviewed-component');assert.equal(resolveNoticeCoverage([{...first,vendors:['vendor/unreviewed']}],approved).unresolved[0].reason,'unreviewed-vendored-source'); + const selected=dirname(first.nativeInputs['cocoapods:React-debug'].ownLicense.podspec.source),source=join(selected,readdirSync(join(root,selected)).find(name=>/\.(cpp|h)$/.test(name))),before=readFileSync(join(root,source));file(source,Buffer.concat([before,Buffer.from('\n// Unreviewed source drift\n')]));assert.equal(resolveNoticeCoverage(capture().components,approved).unresolved.find(value=>value.component===first.id).reason,'native-source-digest-mismatch');file(source,before); + const text=readFileSync(join(root,'node_modules/react-native/LICENSE'));file('node_modules/react-native/LICENSE',Buffer.concat([text,Buffer.from('UNREVIEWED_TEXT')]));assert.throws(capture,/Bound licence input bytes changed/);file('node_modules/react-native/LICENSE',text); + lock['EXTERNAL SOURCES']['React-debug'][':path']='../node_modules/react-native/ReactCommon/react/featureflags';file('ios/Podfile.lock',lock);assert.throws(capture,/Require the exact selected Pod source|Expected values/); +})); test('Expo JavaScript review cannot infer Pod/native/vendor, unknown, changed text/NOTICE or Android approval',()=>runtimeNoticeFixture(({root,file,approved,pins,capture})=>{ const items=capture();for(const item of items){const changed=structuredClone(item);changed.nativeInputs={unreviewedNative:true};assert.equal(resolveNoticeCoverage([changed],approved).unresolved[0].reason,'native-source-digest-mismatch');assert.equal(resolveNoticeCoverage([{...item,id:item.id+'-unknown'}],approved).unresolved[0].reason,'unreviewed-component');assert.equal(resolveNoticeCoverage([{...item,id:item.id.slice(0,item.id.lastIndexOf('@')+1)+'999',version:'999'}],approved).unresolved[0].reason,'unreviewed-component');} assert.equal(resolveNoticeCoverage(items,approved,'android').unresolved.length,25);const font=items.find(value=>value.id==='npm:expo-font@57.0.4'),speech=items.find(value=>value.id==='npm:expo-speech-recognition@57.0.0');assert.equal(resolveNoticeCoverage([{...font,id:'cocoapods:ExpoFont@57.0.4',name:'ExpoFont',ecosystem:'cocoapods'}],approved).unresolved[0].reason,'native-source-digest-mismatch');assert.equal(resolveNoticeCoverage([{...speech,id:'cocoapods:ExpoSpeechRecognition@57.0.0',name:'ExpoSpeechRecognition',ecosystem:'cocoapods'}],approved).unresolved[0].reason,'unreviewed-component');