Update shared Gaia workflows and Sargo development tooling

Fix camera switching and Contacts/SMS activity routing across shared hosts.
Integrate Marketplace installation and native standalone recovery tools,
Bluetooth runtime packages and trusted certificates.

Update English/French READMEs and the shared changelog with tested scope
and remaining radio/audio/Bluetooth limitations. Preview17 remains the
existing APK; no new native image is released.

Validation: shared JavaScript and native Python tests; Android packaging
and payload parity from an isolated publication checkout.
This commit is contained in:
OverSu 2026-10-08 14:27:17 +02:00
commit 0041cd9660
101 changed files with 3883 additions and 335 deletions

View file

@ -0,0 +1,46 @@
/* SPDX-License-Identifier: MPL-2.0
* Read stdin only. Emit offsets and nonzero 64 KiB blocks for a host-side backup.
* No device is opened or written by this process.
*/
#define _start unused_stage0_start
#include "init.c"
#undef _start
static unsigned long buffer[8192];
static void stop(long code) {
call(NR_exit, code, 0, 0, 0, 0);
for (;;) {}
}
static void write_all(const void *data, long length) {
const char *p = data;
while (length) {
long n = call(NR_write, 1, (long)p, length, 0, 0);
if (n == -4) continue;
if (n <= 0) stop(3);
p += n;
length -= n;
}
}
__attribute__((noreturn)) void _start(void) {
unsigned long offset = 0;
write_all("TUNRAW1\0", 8);
for (;;) {
long n = call(NR_read, 0, (long)buffer, sizeof(buffer), 0, 0);
if (n == -4) continue;
if (n < 0) stop(2);
if (!n) break;
unsigned long bits = 0;
long i = 0;
for (; i + 8 <= n; i += 8) bits |= buffer[i / 8];
for (; i < n; ++i) bits |= ((unsigned char *)buffer)[i];
if (bits) {
unsigned long header[2] = {offset, (unsigned long)n};
write_all(header, sizeof(header));
write_all(buffer, n);
}
offset += n;
}
unsigned long end[2] = {offset, 0};
write_all(end, sizeof(end));
call(NR_exit, 0, 0, 0, 0, 0);
for (;;) {}
}

View file

@ -0,0 +1,14 @@
#!/bin/busybox sh
# SPDX-License-Identifier: MPL-2.0
# Mount only the prepared Tundra volume. Never format or guess an old filesystem.
set -eu
# VOLUME_CONFIGURATION
mkdir -p /volume
blkid "$VOLUME_DEVICE" | grep -F "UUID=\"$VOLUME_UUID\"" >/dev/null
repaired=0
e2fsck -p "$VOLUME_DEVICE" || repaired=$?
test "$repaired" -le 1
mount -t ext4 -o "$VOLUME_OPTIONS" "$VOLUME_DEVICE" /volume
test "$(cat /volume/tundra-volume-id)" = "$VOLUME_UUID"
echo TUNDRA_STANDALONE_VOLUME_READY
exec /bin/sh /native-root.sh

View file

@ -67,8 +67,10 @@ export async function run(win) {
.then(async response=>dump(`TUNDRA_HTTP ${response.status} ${(await response.text()).length}\n`))
.catch(error=>dump('TUNDRA_HTTP_ERROR '+error+' offline='+Services.io.offline+'\n'));
async function evaluate(app, code, message = 'Evaluate') {
const prefix=app==='contacts'?'http://communications.localhost:8765/contacts/':
app==='dialer'?'http://communications.localhost:8765/dialer/':`http://${app}.localhost:8765/`;
function find(context) {
if(context.currentWindowGlobal?.documentURI.spec.startsWith(`http://${app}.localhost:8765/`)) return context;
if(context.currentWindowGlobal?.documentURI.spec.startsWith(prefix)) return context;
for(const child of context.children) {const found=find(child); if(found) return found;}
return null;
}
@ -90,14 +92,15 @@ export async function run(win) {
}
throw new Error(`${app}: timeout: ${last}`);
}
async function tap(app, selector) {
if(app==='keyboard') selector='.keyboard-type-container[data-active] '+selector;
await wait(app, `const e=document.querySelector(${JSON.stringify(selector)});if(!e)return false;const r=e.getBoundingClientRect();return r.width>0 && r.height>0 && e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2));`);
await sleep(600);
const point = await evaluate(app, `const e=document.querySelector(${JSON.stringify(selector)});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};`);
await evaluate(app,point,'Tap');
await sleep(350);
}
async function tap(app, selector) {
if(app==='keyboard') selector='.keyboard-type-container[data-active] '+selector;
const point=await wait(app, `for(const e of document.querySelectorAll(${JSON.stringify(selector)})) {
const r=e.getBoundingClientRect(),x=r.x+r.width/2,y=r.y+r.height/2;
if(x>0 && y>0 && x<innerWidth && y<innerHeight && e.contains(document.elementFromPoint(x,y)))return {x,y};
}return false;`);
await evaluate(app,point,'Tap');
await sleep(350);
}
try {
const lock=await IOUtils.readJSON(`${root}/engine-lock.json`);
if(report.engine.version!==lock.version || !report.engine.ABI.startsWith('aarch64'))
@ -229,241 +232,7 @@ export async function run(win) {
}
// Fixed diagnostics can be requested again over the private maintenance SSH.
// Markers never contain scripts or arbitrary commands.
async function requestedUiChecks() {
const cameraMarker = `${root}/logs/test-camera`;
if (await IOUtils.exists(cameraMarker)) {
await IOUtils.remove(cameraMarker);
const cameraReport = {bootId:report.bootId, imagesRetained:false, passed:false};
try {
await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});return true;");
await wait('system', "return !Service.query('locked');");
await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://camera.localhost:8765/manifest.webapp'});return true;");
await wait('camera', "return !!window.app?.camera?.mozCamera && document.querySelector('video')?.videoWidth>0;");
// Gaia remembers the selected lens; establish a known starting state.
await evaluate('camera', "if(app.settings.cameras.selected('key')!=='back')app.settings.cameras.next();return true;");
await wait('camera', "return app.settings.cameras.selected('key')==='back' && !!app.camera.mozCamera && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
cameraReport.gaia = await evaluate('camera', `
const video=document.querySelector('video');
const camera=app.camera.mozCamera;
window.__cameraBeforeSwitch=camera;
const capabilities=camera.capabilities;
let focus=null;
if(capabilities.maxFocusAreas>0) {
camera.setFocusAreas([{top:-150,left:-150,bottom:150,right:150}]);
await camera.autoFocus();focus=true;
}
if(capabilities.zoomRatios.length>1) {
camera.zoom=2;
await new Promise(r=>setTimeout(r,700));
}
const testedZoom=camera.zoom;
camera.zoom=1;
const photo=await camera.takePicture();
const bitmap=await createImageBitmap(photo);
const picture={width:bitmap.width,height:bitmap.height};bitmap.close();
return {width:video.videoWidth,height:video.videoHeight,native:camera.vulpesNative===true,
picture,focus,zoom:testedZoom,focusAreas:capabilities.maxFocusAreas,
jpegBytes:photo.size,jpegType:photo.type,errors:window.__vulpesDiagnostics};
`);
cameraReport.performance = await evaluate('camera', `
const stream=app.camera.mozCamera, before=stream.vulpesTiming?.frames||0;
const start=performance.now();await new Promise(r=>setTimeout(r,4000));
const elapsed=performance.now()-start;
const hud=document.querySelector('.hud');
const button=document.querySelector('.js-camera');
return {fps:(stream.vulpesTiming.frames-before)*1000/elapsed,
conversionMs:stream.vulpesTiming.conversionMs/stream.vulpesTiming.frames,
switchVisible:getComputedStyle(button).opacity!=='0' && hud.getAttribute('camera-enabled')==='true',
flashVisible:getComputedStyle(document.querySelector('.js-flash')).visibility==='visible'};
`);
await evaluate('camera', "document.querySelector('.js-camera').click();return true;");
await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__cameraBeforeSwitch && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
cameraReport.switched = await evaluate('camera', `
const photo=await app.camera.mozCamera.takePicture();
window.__cameraBeforeHome=app.camera.mozCamera;
return {camera:app.settings.cameras.selected('key'),jpegBytes:photo.size,
previousStopped:window.__cameraBeforeSwitch.getTracks().every(t=>t.readyState==='ended')};
`);
cameraReport.roundTrips = [];
for (let i=0; i<4; i++) {
await evaluate('camera', "window.__switchFrom=app.camera.mozCamera;return true;");
await tap('camera', '.js-camera');
await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__switchFrom && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
cameraReport.roundTrips.push(await evaluate('camera', `
const camera=app.camera.mozCamera, before=camera.vulpesTiming.frames;
await new Promise(r=>setTimeout(r,700));
return {camera:app.settings.cameras.selected('key'),
frames:camera.vulpesTiming.frames-before,
previousStopped:window.__switchFrom.getTracks().every(t=>t.readyState==='ended')};
`));
}
await evaluate('camera', 'window.__cameraBeforeHome=app.camera.mozCamera;return true;');
// Test Gaia's Home action, independent of a held pointer on the shell button.
cameraReport.beforeHome = await evaluate('system', "return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
await evaluate('system', "dispatchEvent(new CustomEvent('home'));return true;");
cameraReport.afterHome = await evaluate('system', "await new Promise(r=>setTimeout(r,1000));return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
cameraReport.hidden = await wait('camera', 'return document.hidden;');
cameraReport.stopped = await wait('camera', "return window.__cameraBeforeHome.getTracks().every(t=>t.readyState==='ended');");
cameraReport.passed=cameraReport.gaia.native && cameraReport.gaia.picture.width*cameraReport.gaia.picture.height>10000000 && cameraReport.gaia.focus && cameraReport.gaia.zoom>1 && cameraReport.gaia.jpegBytes>1000 && cameraReport.gaia.jpegType==='image/jpeg' && cameraReport.switched.jpegBytes>1000 && cameraReport.switched.previousStopped && cameraReport.stopped && cameraReport.roundTrips.every(r=>r.frames>0 && r.previousStopped);
} catch(error) { cameraReport.error=String(error); }
finally { win.document.getElementById('home')?.click(); }
await IOUtils.writeJSON(`${root}/logs/camera.json`, cameraReport);
}
const smsMarker = `${root}/logs/test-sms-display`;
if (await IOUtils.exists(smsMarker)) {
await IOUtils.remove(smsMarker);
const smsReport = {bootId:report.bootId, passed:false, sent:false};
try {
await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;");
await wait('sms', "return !!window.Navigation && !!window.MessageManager;");
smsReport.threads = await evaluate('sms', `
const threads=await new Promise((resolve,reject)=>{const rows=[];const c=navigator.mozMobileMessage.getThreads();c.onsuccess=()=>{if(c.done)resolve(rows);else{rows.push(c.result);c.continue();}};c.onerror=()=>reject(c.error);});
window.__smsReviewThreads=threads.map(t=>t.id);
return {count:threads.length,validDates:threads.every(t=>Number.isFinite(+t.timestamp))};
`);
smsReport.conversations=[];
for(let i=0;i<Math.min(smsReport.threads.count,5);i++) {
await evaluate('sms', `await Navigation.toPanel('thread',{id:window.__smsReviewThreads[${i}]});return true;`);
await sleep(1500);
smsReport.conversations.push(await evaluate('sms', `
const nodes=[...document.querySelectorAll('#messages-container .message')];
return {bubbles:nodes.length,validDates:nodes.every(e=>Number.isFinite(+e.querySelector('time')?.dataset.time)),
nonempty:nodes.every(e=>!!e.querySelector('.message-content-body')?.textContent),
errorCount:window.__vulpesDiagnostics?.length||0};
`));
}
smsReport.passed=smsReport.threads.validDates && smsReport.conversations.length>0 && smsReport.conversations.every(c=>c.bubbles>0 && c.validDates && c.nonempty);
} catch(error) {smsReport.error=String(error);}
finally {win.document.getElementById('home')?.click();}
await IOUtils.writeJSON(`${root}/logs/sms-display.json`,smsReport);
}
const inputMarker = `${root}/logs/test-keyboard`;
if (await IOUtils.exists(inputMarker)) {
await IOUtils.remove(inputMarker);
const inputReport = {bootId:report.bootId,recordedAt:Date.now(),passed:false,sent:false};
try {
await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
await sleep(1500);
await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
await wait('sms', 'return document.querySelector("#threads-composer-link") && innerWidth>0;');
await evaluate('sms', 'await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
await wait('sms','return !!document.querySelector(".recipient[contenteditable]");');
await tap('sms','.recipient[contenteditable]');
await wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="tel" && !!document.querySelector("button[aria-label=\\"2\\"]");');
await tap('keyboard','button[aria-label="2"]');
await tap('keyboard','button[aria-label="3"]');
inputReport.recipientTyped = await evaluate('sms','return document.querySelector(".recipient[contenteditable]").textContent.endsWith("23");');
const recipientBefore=await evaluate('sms','return document.querySelector(".recipient[contenteditable]").textContent;');
await tap('sms','#messages-input');
await wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="text" && !!document.querySelector("button[aria-label=\\"a\\"]");');
inputReport.keyboard = await evaluate('keyboard', `const e=document.querySelector('button[aria-label="a"]');const r=e.getBoundingClientRect();
window.__keyTrial=[];for(const type of ['touchstart','touchend','pointerdown','click']) e.addEventListener(type,()=>__keyTrial.push(type),{once:true});
return {width:innerWidth,height:innerHeight,key:r.toJSON(),font:getComputedStyle(document.documentElement).fontSize,portrait:app.viewManager.screenInPortraitMode(),engine:app.inputMethodManager.currentIMEngine?.constructor?.name};`);
await tap('keyboard','button[aria-label="a"]');
await sleep(2000);
inputReport.context = await evaluate('keyboard', 'const c=navigator.mozInputMethod.inputcontext;return c&&{id:c.id,type:c.type,inputType:c.inputType,textLength:c.text.length};');
inputReport.message = await evaluate('sms', 'const text=document.querySelector("#messages-input").textContent;return {length:text.length,lowercase:text.includes("a"),uppercase:text.includes("A")};');
inputReport.messageTyped = inputReport.message.lowercase || inputReport.message.uppercase;
inputReport.events = await evaluate('keyboard','return window.__keyTrial;');
inputReport.recipientUnchanged=await evaluate('sms',`return document.querySelector(".recipient[contenteditable]").textContent===${JSON.stringify(recipientBefore)};`);
inputReport.passed=inputReport.recipientTyped && inputReport.messageTyped && inputReport.recipientUnchanged && inputReport.keyboard.portrait && inputReport.keyboard.key.height>=44;
if(!inputReport.messageTyped) {
try {
await evaluate('keyboard','await navigator.mozInputMethod.inputcontext.sendKey(0,97);return true;');
inputReport.directKey = await evaluate('sms','return document.querySelector("#messages-input").textContent.length>0;');
} catch(error) {inputReport.directKeyError=String(error);}
}
inputReport.fonts=await evaluate('sms',null,'RenderedFonts');
for (const face of inputReport.fonts) delete face.samples;
} catch(error) {inputReport.error=String(error);}
finally {win.document.getElementById('home').click();}
await IOUtils.writeJSON(`${root}/logs/keyboard.json`,inputReport);
}
const sendMarker=`${root}/logs/test-send-sms.json`;
if(await IOUtils.exists(sendMarker)) {
const request=await IOUtils.readJSON(sendMarker);
await IOUtils.remove(sendMarker);
const result={bootId:report.bootId,recordedAt:Date.now(),submitted:false,deliveryConfirmed:false};
try {
if(typeof request.recipient!=='string' || !/^\+[0-9]{8,15}$/.test(request.recipient)) throw Error('INVALID_TEST_RECIPIENT');
const {tundraRadio}=ChromeUtils.importESModule('resource://vulpes/host/Tundra.sys.mjs');
const state=await tundraRadio('status');
if(!['registered','roaming'].includes(state.registration?.[0]?.Status?.data)) throw Error('RADIO_NOT_REGISTERED');
await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
await sleep(1000);
await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
await wait('sms','return !!window.Navigation;');
await evaluate('sms','await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
await evaluate('sms','if(ConversationView.recipients.length || document.querySelector(".recipient[contenteditable]").textContent.trim() || Compose.getText())throw Error("TEST_DRAFT_PRESENT");return true;');
await tap('sms','.recipient[contenteditable]');
await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="tel";');
await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(request.recipient)},0,0);return true;`);
await tap('sms','#messages-input');
await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="text";');
const body='Vulpes OS - test SMS '+new Date().toISOString();
await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(body)},0,0);return true;`);
await wait('sms','return !document.querySelector("#messages-send-button").disabled;');
await evaluate('sms',`if(!Navigation.isCurrentPanel('composer') ||
JSON.stringify(ConversationView.recipients.numbers)!==JSON.stringify([${JSON.stringify(request.recipient)}]) ||
Compose.getText()!==${JSON.stringify(body)})throw Error('TEST_MESSAGE_MISMATCH');
document.querySelector('#messages-send-button').click();return true;`);
result.submitted=await wait('sms',`return await new Promise((resolve,reject)=>{
const cursor=navigator.mozMobileMessage.getMessages(null,true);
cursor.onsuccess=()=>{const m=cursor.result;if(!m){resolve(false);return;}
if(m.body===${JSON.stringify(body)}){if(m.delivery==='error'){reject(Error('MODEM_TRANSMISSION_FAILED'));return;}resolve(m.delivery==='sent');return;}cursor.continue();};
cursor.onerror=()=>reject(cursor.error);
});`);
} catch(error) {result.error=String(error);}
finally {win.document.getElementById('home').click();}
await IOUtils.writeJSON(`${root}/logs/send-sms.json`,result);
}
const notificationMarker = `${root}/logs/test-notifications`;
if (await IOUtils.exists(notificationMarker)) {
await IOUtils.remove(notificationMarker);
const result = {bootId:report.bootId,passed:false,silent:true};
let id;
try {
await evaluate('system', `ScreenManager.turnScreenOn(true);await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
await wait('sms', 'return typeof Notification.get === "function";');
await evaluate('system', 'if(!Service.query("locked"))await Service.request("lock");return true;');
result.lockState=await evaluate('system','return {service:Service.query("locked"),direct:lockScreen.locked,preview:NotificationScreen.lockscreenPreview};');
await wait('system', 'return Service.query("locked") && NotificationScreen.lockscreenPreview;');
id = await evaluate('sms', `return await new Promise((resolve,reject)=>{
const n=new Notification('Vulpes — test local',{body:'Notification de qualification',tag:'vulpes-qualification',silent:true});
n.onshow=()=>resolve(n.id);n.onerror=()=>reject(n.error);
});`);
const selector = `[data-notification-id="${id}"]`;
result.tray = await wait('system', `return !!NotificationScreen.container.querySelector(${JSON.stringify(selector)});`);
result.lockscreen = await wait('system', `return !!NotificationScreen.getLockScreenContainer()?.querySelector(${JSON.stringify(selector)});`);
await evaluate('sms', `const rows=await Notification.get({tag:'vulpes-qualification'});rows.forEach(n=>n.close());return true;`);
result.removed = await wait('system', `return !NotificationScreen.container.querySelector(${JSON.stringify(selector)}) && !NotificationScreen.getLockScreenContainer()?.querySelector(${JSON.stringify(selector)});`);
result.passed = result.tray && result.lockscreen && result.removed;
} catch(error) { result.error=String(error); }
finally {
if(id) await evaluate('sms', `for(const n of await Notification.get({tag:'vulpes-qualification'})) n.close();return true;`).catch(()=>{});
win.document.getElementById('home').click();
}
await IOUtils.writeJSON(`${root}/logs/notifications.json`,result);
}
}
await requestedUiChecks();
let uiCheckRunning=false;
const uiCheckTimer=setInterval(async()=>{
if(uiCheckRunning) return;
uiCheckRunning=true;
try {
const reloadMarker=`${root}/logs/test-reload-gaia`;
if(await IOUtils.exists(reloadMarker)) {
await IOUtils.remove(reloadMarker);
win.document.querySelector('browser').reloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE);
await wait('system', "return document.body?.getAttribute('ready-state')==='fullyLoaded';");
await sleep(1500);
}
await requestedUiChecks();
} catch(error) {dump('TUNDRA_UI_CHECK_ERROR '+error+'\n');}
finally {uiCheckRunning=false;}
},3000);
win.addEventListener('unload',()=>clearInterval(uiCheckTimer),{once:true});
async function requestedControls() {
const controlsMarker = `${root}/logs/test-controls`;
if (await IOUtils.exists(controlsMarker)) {
await IOUtils.remove(controlsMarker);
@ -569,6 +338,392 @@ export async function run(win) {
controls.brightness=brightnessCheck;
await IOUtils.writeJSON(`${root}/logs/controls.json`, controls);
}
}
async function requestedUiChecks() {
await requestedControls();
const marketplaceMarker = `${root}/logs/test-marketplace-views`;
if (await IOUtils.exists(marketplaceMarker)) {
await IOUtils.remove(marketplaceMarker);
const result = {bootId:report.bootId,passed:false,checks:{},fixture:'http://127.0.0.1:8767/'};
const {Views}=ChromeUtils.importESModule('resource://vulpes/host/Views.sys.mjs');
const {Runtime}=ChromeUtils.importESModule('resource://vulpes/host/Runtime.sys.mjs');
const until=async check=>{
for(let i=0;i<80;i++){if(await check())return true;await sleep(250);}
throw Error('Marketplace view check timed out');
};
const visible=()=>[...Views.entries.values()].some(e=>e.browser.style.visibility==='visible');
const open=async()=>{
await evaluate('system', 'await VulpesCompat.call("apps.launch",{manifestURL:"http://marketplace.localhost:8765/manifest.webapp"});return true;');
await wait('marketplace', 'return !!document.getElementById("browse");');
await sleep(1000);
await evaluate('marketplace', 'document.getElementById("browse").click();document.getElementById("catalogue").src="http://127.0.0.1:8767/";return true;');
await until(visible);
};
try {
await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
await sleep(1000);
// This fixture tests web-view ownership, not the lockscreen gesture.
// Service.unlock can leave the legacy overlay visible during clock teardown.
result.unlockFixture=await evaluate('system', 'let note=null;if(window.lockScreen){try{if(lockScreen.locked)lockScreen.unlock(true);}catch(e){note=String(e);}lockScreen.locked=false;lockScreen.overlay.classList.add("unlocked");}return {forced:true,cleanupError:note};');
await open();
result.checks.belowStatusBar=[...Views.entries.values()].filter(e=>e.browser.style.visibility==='visible').every(e=>parseFloat(e.browser.style.top)>=20);
await evaluate('marketplace', 'document.getElementById("catalogue").src="https://marketplace-offline.invalid/";return true;');
result.checks.offlineMessage=await wait('marketplace', 'return !document.getElementById("message").hidden && /inaccessible|unavailable/.test(document.getElementById("status").textContent);');
result.checks.errorHidden=await until(()=>!visible());
await open();
result.checks.retry=true;
win.document.getElementById('home').click();
result.checks.home=await until(()=>!visible());
await sleep(1000);
await open();
Runtime.broadcast('holdhome',{},'system');
await wait('system','return appWindowManager.taskManager.isActive();');
result.checks.taskSwitcher=await until(()=>!visible());
await sleep(1000);
await evaluate('system', 'const row=[...appWindowManager.taskManager.appToCardMap].find(([app])=>app.origin.includes("marketplace.localhost"));row[1].element.querySelector(".close-button").click();return true;');
result.checks.closed=await until(()=>Views.entries.size===0);
result.passed=Object.values(result.checks).every(Boolean);
} catch(error) {
result.error=String(error);
result.views=[...Views.entries.values()].map(e=>({geometry:e.geometry,style:e.browser.style.cssText}));
} finally {win.document.getElementById('home').click();}
await IOUtils.writeJSON(`${root}/logs/marketplace-views.json`,result);
}
const ticketMarker = `${root}/logs/test-tickets-20261005`;
if (await IOUtils.exists(ticketMarker)) {
await IOUtils.remove(ticketMarker);
const result = {bootId:report.bootId,passed:false,checks:{}};
try {
await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});return true;");
await wait('system', "return !Service.query('locked');");
await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://settings.localhost:8765/manifest.webapp'});return true;");
await wait('settings', "return document.readyState==='complete';");
await evaluate('settings', "window.__ticketLanguage=(await VulpesCompat.call('settings.get',{key:'language.current'})).value;return true;");
for (const lang of ['en-US','fr']) {
await evaluate('settings', `await VulpesCompat.call('settings.set',{values:{'language.current':${JSON.stringify(lang)}}});return true;`);
result.checks['language_'+lang] = await wait('homescreen', `return navigator.language===${JSON.stringify(lang)} && document.documentElement.lang===${JSON.stringify(lang)};`);
}
result.checks.developmentHidden = await evaluate('homescreen', "return [...document.querySelectorAll('gaia-app-icon')].every(e=>!/(uitest|test-|membuster|template|ds-test)/.test(e.app?.origin || ''));");
result.checks.snap = await evaluate('homescreen', "return getComputedStyle(document.getElementById('panels')).scrollSnapType==='x mandatory';");
await evaluate('settings', "window.__ticketPicker=new MozActivity({name:'pick',data:{type:'image/*'}});return true;");
await wait('wallpaper', "return !!window.Wallpaper?.pickActivity && document.querySelectorAll('.wallpaper').length>1;");
await evaluate('wallpaper', "document.querySelectorAll('.wallpaper')[1].click();return true;");
result.checks.wallpaperPicker = await wait('settings', "return __ticketPicker.readyState==='done' && __ticketPicker.result?.blob instanceof Blob && __ticketPicker.result.blob.size>0;");
await evaluate('settings', "window.__ticketPicker=new MozActivity({name:'pick',data:{type:'alerttone'}});return true;");
await wait('ringtones', "return document.body.dataset.ready==='true' && document.querySelectorAll('gaia-radio').length>3;");
await evaluate('ringtones', "document.querySelectorAll('gaia-radio')[2].click();return true;");
await wait('ringtones', "return !document.getElementById('set').disabled;");
await evaluate('ringtones', "document.getElementById('set').click();return true;");
result.checks.tonePicker = await wait('settings', "return __ticketPicker.readyState==='done' && __ticketPicker.result?.blob instanceof Blob && __ticketPicker.result.blob.size>0;");
result.passed=Object.values(result.checks).every(Boolean);
} catch(error) {result.error=String(error);}
finally {
await evaluate('settings', "if(window.__ticketLanguage)await VulpesCompat.call('settings.set',{values:{'language.current':__ticketLanguage}});return true;").catch(()=>{});
win.document.getElementById('home').click();
}
await IOUtils.writeJSON(`${root}/logs/tickets-20261005.json`,result);
}
const cameraMarker = `${root}/logs/test-camera`;
if (await IOUtils.exists(cameraMarker)) {
await IOUtils.remove(cameraMarker);
const cameraReport = {bootId:report.bootId, imagesRetained:false, passed:false};
try {
await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});return true;");
await wait('system', "return !Service.query('locked');");
await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://camera.localhost:8765/manifest.webapp'});return true;");
await wait('camera', "return !!window.app?.camera?.mozCamera && document.querySelector('video')?.videoWidth>0;");
// Gaia remembers the selected lens; establish a known starting state.
await evaluate('camera', "if(app.settings.cameras.selected('key')!=='back')app.settings.cameras.next();return true;");
await wait('camera', "return app.settings.cameras.selected('key')==='back' && !!app.camera.mozCamera && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
cameraReport.gaia = await evaluate('camera', `
const video=document.querySelector('video');
const camera=app.camera.mozCamera;
window.__cameraBeforeSwitch=camera;
const capabilities=camera.capabilities;
let focus=null, focusError=null;
if(capabilities.maxFocusAreas>0) {
try {
camera.setFocusAreas([{top:-150,left:-150,bottom:150,right:150}]);
await camera.autoFocus();focus=true;
} catch(error) { focus=false;focusError=String(error); }
}
if(capabilities.zoomRatios.length>1) {
camera.zoom=2;
await new Promise(r=>setTimeout(r,700));
}
const testedZoom=camera.zoom;
camera.zoom=1;
const photo=await camera.takePicture();
const bitmap=await createImageBitmap(photo);
const picture={width:bitmap.width,height:bitmap.height};bitmap.close();
return {width:video.videoWidth,height:video.videoHeight,native:camera.vulpesNative===true,
picture,focus,focusError,zoom:testedZoom,focusAreas:capabilities.maxFocusAreas,
jpegBytes:photo.size,jpegType:photo.type,errors:window.__vulpesDiagnostics};
`);
cameraReport.performance = await evaluate('camera', `
const stream=app.camera.mozCamera, before=stream.vulpesTiming?.frames||0;
const start=performance.now();await new Promise(r=>setTimeout(r,4000));
const elapsed=performance.now()-start;
const hud=document.querySelector('.hud');
const button=document.querySelector('.js-camera');
return {fps:(stream.vulpesTiming.frames-before)*1000/elapsed,
conversionMs:stream.vulpesTiming.conversionMs/stream.vulpesTiming.frames,
switchVisible:getComputedStyle(button).opacity!=='0' && hud.getAttribute('camera-enabled')==='true',
flashVisible:getComputedStyle(document.querySelector('.js-flash')).visibility==='visible'};
`);
await evaluate('camera', "document.querySelector('.js-camera').click();return true;");
await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__cameraBeforeSwitch && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
cameraReport.switched = await evaluate('camera', `
const photo=await app.camera.mozCamera.takePicture();
window.__cameraBeforeHome=app.camera.mozCamera;
return {camera:app.settings.cameras.selected('key'),jpegBytes:photo.size,
previousStopped:window.__cameraBeforeSwitch.getTracks().every(t=>t.readyState==='ended')};
`);
cameraReport.roundTrips = [];
for (let i=0; i<4; i++) {
await evaluate('camera', "window.__switchFrom=app.camera.mozCamera;return true;");
await tap('camera', '.js-camera');
await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__switchFrom && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
cameraReport.roundTrips.push(await evaluate('camera', `
const camera=app.camera.mozCamera, before=camera.vulpesTiming.frames;
await new Promise(r=>setTimeout(r,700));
return {camera:app.settings.cameras.selected('key'),
frames:camera.vulpesTiming.frames-before,
previousStopped:window.__switchFrom.getTracks().every(t=>t.readyState==='ended')};
`));
}
await evaluate('camera', 'window.__cameraBeforeHome=app.camera.mozCamera;return true;');
// Test Gaia's Home action, independent of a held pointer on the shell button.
await evaluate('camera', "window.__cameraFilesBefore=new Set((await VulpesCompat.call('media.request',{type:'pictures',operation:'enumerate'})).map(f=>f.name));return true;");
await tap('camera','.js-capture');
await wait('camera', "const files=await VulpesCompat.call('media.request',{type:'pictures',operation:'enumerate'});window.__cameraSavedPhoto=files.find(f=>!__cameraFilesBefore.has(f.name));return !!__cameraSavedPhoto;");
cameraReport.savedPhoto=await evaluate('camera', 'return {name:__cameraSavedPhoto.name,size:__cameraSavedPhoto.size,type:__cameraSavedPhoto.type};');
await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://gallery.localhost:8765/manifest.webapp'});return true;");
await wait('gallery', `return typeof files!=='undefined' && files.some(f=>f.name===${JSON.stringify(cameraReport.savedPhoto.name)} && f.metadata.width>0 && f.metadata.thumbnail);`);
await evaluate('gallery', `document.querySelector('[data-filename="'+${JSON.stringify(cameraReport.savedPhoto.name)}+'"]')?.click();return true;`);
cameraReport.galleryOpened=await wait('gallery', "return document.body.classList.contains('fullscreenView');");
await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://camera.localhost:8765/manifest.webapp'});return true;");
await wait('camera', "return !!app.camera.mozCamera && !app.camera.isBusy && !document.hidden;");
await evaluate('camera', `await navigator.getDeviceStorage('pictures').delete(${JSON.stringify(cameraReport.savedPhoto.name)});window.__cameraBeforeHome=app.camera.mozCamera;return true;`);
cameraReport.captureStored=cameraReport.savedPhoto.size>1000;
delete cameraReport.savedPhoto.name;
cameraReport.beforeHome = await evaluate('system', "return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
await evaluate('system', "dispatchEvent(new CustomEvent('home'));return true;");
cameraReport.afterHome = await evaluate('system', "await new Promise(r=>setTimeout(r,1000));return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
cameraReport.hidden = await wait('camera', 'return document.hidden;');
cameraReport.stopped = await wait('camera', "return window.__cameraBeforeHome.getTracks().every(t=>t.readyState==='ended');");
cameraReport.passed=cameraReport.captureStored && cameraReport.galleryOpened && cameraReport.gaia.native && cameraReport.gaia.picture.width*cameraReport.gaia.picture.height>10000000 && cameraReport.gaia.focus && cameraReport.gaia.zoom>1 && cameraReport.gaia.jpegBytes>1000 && cameraReport.gaia.jpegType==='image/jpeg' && cameraReport.switched.jpegBytes>1000 && cameraReport.switched.previousStopped && cameraReport.stopped && cameraReport.roundTrips.every(r=>r.frames>0 && r.previousStopped);
} catch(error) { cameraReport.error=String(error); }
finally { win.document.getElementById('home')?.click(); }
await IOUtils.writeJSON(`${root}/logs/camera.json`, cameraReport);
}
const contactsMarker=`${root}/logs/test-contacts`;
if(await IOUtils.exists(contactsMarker)) {
await IOUtils.remove(contactsMarker);
const result={bootId:report.bootId,passed:false}, name='Vulpes QA '+Date.now();
let id;
const lookup=`const r=navigator.mozContacts.find({filterBy:['givenName'],filterOp:'equals',filterValue:${JSON.stringify(name)}});
return await new Promise((ok,no)=>{r.onsuccess=()=>ok(r.result[0]||null);r.onerror=()=>no(r.error);});`;
const field=async(selector,value)=>evaluate('contacts',`const e=document.querySelector(${JSON.stringify(selector)});
e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true;`);
try {
await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://communications.localhost:8765/manifest.webapp',entryPoint:'contacts'});return true;");
await wait('contacts', "return typeof Loader!=='undefined' && window.MainNavigation?.currentView()==='view-contacts-list';");
await tap('contacts','#add-contact-button');
await wait('contacts', "return MainNavigation.currentView()==='view-contact-form' && !!document.querySelector('#number_0');");
await field('#givenName',name);await field('#familyName','Élodie Test');
await field('#number_0','5550199');await field('#email_0','qa@example.invalid');
await tap('contacts','#save-button');
const saved=await wait('contacts',lookup);id=saved.id;
result.created=saved.tel[0].value==='5550199';
const selector='[data-uuid="'+id+'"]';
await tap('contacts',selector);await tap('contacts','#edit-contact-button');
await wait('contacts', "return MainNavigation.currentView()==='view-contact-form';");
await field('#familyName','Élodie Modifiée');await tap('contacts','#save-button');
result.edited=await wait('contacts', "return document.querySelector('#contact-name-title')?.textContent.includes('Élodie Modifiée');");
await evaluate('contacts', "document.querySelector('#send-sms-button-0').scrollIntoView({block:'center'});return true;");
await tap('contacts','#send-sms-button-0');
result.sms=await wait('sms', "return !document.hidden && window.ConversationView?.recipients?.numbers.includes('5550199');");
await tap('sms','#messages-contact-pick-button');
await wait('contacts', 'return !document.hidden && ActivityHandler.currentlyHandling;');
await tap('contacts',selector);await tap('contacts','[data-l10n-id=pick_destination]');
await wait('contacts','return !ActivityHandler.currentlyHandling;');
result.picker=await wait('sms', "return !document.hidden && ConversationView.recipients.numbers.includes('5550199');");
await evaluate('sms', "new MozActivity({name:'dial',data:{type:'webtelephony/number',number:'5550199'}});return true;");
result.dialer=await wait('dialer', "return !document.hidden && document.querySelector('#phone-number-view')?.value==='5550199';");
result.passed=result.created&&result.edited&&result.sms&&result.picker&&result.dialer;
} catch(error) {result.error=String(error);}
finally {
try {
if(!id) id=(await evaluate('contacts',lookup))?.id;
if(id) {
await evaluate('contacts', `const r=navigator.mozContacts.remove(${JSON.stringify(id)});await new Promise((ok,no)=>{r.onsuccess=ok;r.onerror=()=>no(r.error);});return true;`);
result.temporaryContactRemoved=!(await evaluate('contacts',lookup));
}
} catch(error) {result.cleanupError=String(error);}
win.document.getElementById('home')?.click();
}
await IOUtils.writeJSON(`${root}/logs/contacts.json`,result);
}
const smsMarker = `${root}/logs/test-sms-display`;
if (await IOUtils.exists(smsMarker)) {
await IOUtils.remove(smsMarker);
const smsReport = {bootId:report.bootId, passed:false, sent:false};
try {
await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;");
await wait('sms', "return !!window.Navigation && !!window.MessageManager;");
smsReport.threads = await evaluate('sms', `
const threads=await new Promise((resolve,reject)=>{const rows=[];const c=navigator.mozMobileMessage.getThreads();c.onsuccess=()=>{if(c.done)resolve(rows);else{rows.push(c.result);c.continue();}};c.onerror=()=>reject(c.error);});
window.__smsReviewThreads=threads.map(t=>t.id);
return {count:threads.length,validDates:threads.every(t=>Number.isFinite(+t.timestamp))};
`);
smsReport.conversations=[];
for(let i=0;i<Math.min(smsReport.threads.count,5);i++) {
await evaluate('sms', `await Navigation.toPanel('thread',{id:window.__smsReviewThreads[${i}]});return true;`);
await sleep(1500);
smsReport.conversations.push(await evaluate('sms', `
const nodes=[...document.querySelectorAll('#messages-container .message')];
return {bubbles:nodes.length,validDates:nodes.every(e=>Number.isFinite(+e.querySelector('time')?.dataset.time)),
nonempty:nodes.every(e=>!!e.querySelector('.message-content-body')?.textContent),
errorCount:window.__vulpesDiagnostics?.length||0};
`));
}
smsReport.passed=smsReport.threads.validDates && smsReport.conversations.length>0 && smsReport.conversations.every(c=>c.bubbles>0 && c.validDates && c.nonempty);
} catch(error) {smsReport.error=String(error);}
finally {win.document.getElementById('home')?.click();}
await IOUtils.writeJSON(`${root}/logs/sms-display.json`,smsReport);
}
const inputMarker = `${root}/logs/test-keyboard`;
if (await IOUtils.exists(inputMarker)) {
await IOUtils.remove(inputMarker);
const inputReport = {bootId:report.bootId,recordedAt:Date.now(),passed:false,sent:false};
try {
await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
await sleep(1500);
await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
await wait('sms', 'return document.querySelector("#threads-composer-link") && innerWidth>0;');
await evaluate('sms', 'await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
await wait('sms','return !!document.querySelector(".recipient[contenteditable]");');
await tap('sms','.recipient[contenteditable]');
await wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="tel" && !!document.querySelector("button[aria-label=\\"2\\"]");');
await tap('keyboard','button[aria-label="2"]');
await tap('keyboard','button[aria-label="3"]');
inputReport.recipientTyped = await evaluate('sms','return document.querySelector(".recipient[contenteditable]").textContent.endsWith("23");');
const recipientBefore=await evaluate('sms','return document.querySelector(".recipient[contenteditable]").textContent;');
await tap('sms','#messages-input');
await wait('keyboard', 'return navigator.mozInputMethod?.inputcontext?.inputType==="text" && !!document.querySelector("button[aria-label=\\"a\\"]");');
inputReport.keyboard = await evaluate('keyboard', `const e=document.querySelector('button[aria-label="a"]');const r=e.getBoundingClientRect();
window.__keyTrial=[];for(const type of ['touchstart','touchend','pointerdown','click']) e.addEventListener(type,()=>__keyTrial.push(type),{once:true});
return {width:innerWidth,height:innerHeight,key:r.toJSON(),font:getComputedStyle(document.documentElement).fontSize,portrait:app.viewManager.screenInPortraitMode(),engine:app.inputMethodManager.currentIMEngine?.constructor?.name};`);
await tap('keyboard','button[aria-label="a"]');
await sleep(2000);
inputReport.context = await evaluate('keyboard', 'const c=navigator.mozInputMethod.inputcontext;return c&&{id:c.id,type:c.type,inputType:c.inputType,textLength:c.text.length};');
inputReport.message = await evaluate('sms', 'const text=document.querySelector("#messages-input").textContent;return {length:text.length,lowercase:text.includes("a"),uppercase:text.includes("A")};');
inputReport.messageTyped = inputReport.message.lowercase || inputReport.message.uppercase;
inputReport.events = await evaluate('keyboard','return window.__keyTrial;');
inputReport.recipientUnchanged=await evaluate('sms',`return document.querySelector(".recipient[contenteditable]").textContent===${JSON.stringify(recipientBefore)};`);
inputReport.passed=inputReport.recipientTyped && inputReport.messageTyped && inputReport.recipientUnchanged && inputReport.keyboard.portrait && inputReport.keyboard.key.height>=44;
if(!inputReport.messageTyped) {
try {
await evaluate('keyboard','await navigator.mozInputMethod.inputcontext.sendKey(0,97);return true;');
inputReport.directKey = await evaluate('sms','return document.querySelector("#messages-input").textContent.length>0;');
} catch(error) {inputReport.directKeyError=String(error);}
}
inputReport.fonts=await evaluate('sms',null,'RenderedFonts');
for (const face of inputReport.fonts) delete face.samples;
} catch(error) {inputReport.error=String(error);}
finally {win.document.getElementById('home').click();}
await IOUtils.writeJSON(`${root}/logs/keyboard.json`,inputReport);
}
const sendMarker=`${root}/logs/test-send-sms.json`;
if(await IOUtils.exists(sendMarker)) {
const request=await IOUtils.readJSON(sendMarker);
await IOUtils.remove(sendMarker);
const result={bootId:report.bootId,recordedAt:Date.now(),submitted:false,deliveryConfirmed:false};
try {
if(typeof request.recipient!=='string' || !/^\+[0-9]{8,15}$/.test(request.recipient)) throw Error('INVALID_TEST_RECIPIENT');
const {tundraRadio}=ChromeUtils.importESModule('resource://vulpes/host/Tundra.sys.mjs');
const state=await tundraRadio('status');
if(!['registered','roaming'].includes(state.registration?.[0]?.Status?.data)) throw Error('RADIO_NOT_REGISTERED');
await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
await sleep(1000);
await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
await wait('sms','return !!window.Navigation;');
await evaluate('sms','await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
await evaluate('sms','if(ConversationView.recipients.length || document.querySelector(".recipient[contenteditable]").textContent.trim() || Compose.getText())throw Error("TEST_DRAFT_PRESENT");return true;');
await tap('sms','.recipient[contenteditable]');
await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="tel";');
await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(request.recipient)},0,0);return true;`);
await tap('sms','#messages-input');
await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="text";');
const body='Vulpes OS - test SMS '+new Date().toISOString();
await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(body)},0,0);return true;`);
await wait('sms','return !document.querySelector("#messages-send-button").disabled;');
result.preSend=await evaluate('sms',`return {composer:Navigation.isCurrentPanel('composer'),
recipientMatch:JSON.stringify(ConversationView.recipients.numbers.map(n=>navigator.mozPhoneNumberService.normalize(n)))===JSON.stringify([${JSON.stringify(request.recipient)}]),
recipientCount:ConversationView.recipients.numbers.length,
bodyMatch:Compose.getText()===${JSON.stringify(body)},bodyLength:Compose.getText().length,
expectedLength:${body.length}};`);
if(!result.preSend.composer || !result.preSend.recipientMatch || !result.preSend.bodyMatch)
throw Error('TEST_MESSAGE_MISMATCH');
await evaluate('sms',"document.querySelector('#messages-send-button').click();return true;");
result.submitted=await wait('sms',`return await new Promise((resolve,reject)=>{
const cursor=navigator.mozMobileMessage.getMessages(null,true);
cursor.onsuccess=()=>{const m=cursor.result;if(!m){resolve(false);return;}
if(m.body===${JSON.stringify(body)}){if(m.delivery==='error'){reject(Error('MODEM_TRANSMISSION_FAILED'));return;}resolve(m.delivery==='sent');return;}cursor.continue();};
cursor.onerror=()=>reject(cursor.error);
});`);
} catch(error) {result.error=String(error);}
finally {win.document.getElementById('home').click();}
await IOUtils.writeJSON(`${root}/logs/send-sms.json`,result);
}
const notificationMarker = `${root}/logs/test-notifications`;
if (await IOUtils.exists(notificationMarker)) {
await IOUtils.remove(notificationMarker);
const result = {bootId:report.bootId,passed:false,silent:true};
let id;
try {
await evaluate('system', `ScreenManager.turnScreenOn(true);await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
await wait('sms', 'return typeof Notification.get === "function";');
await evaluate('system', 'if(!Service.query("locked"))await Service.request("lock");return true;');
result.lockState=await evaluate('system','return {service:Service.query("locked"),direct:lockScreen.locked,preview:NotificationScreen.lockscreenPreview};');
await wait('system', 'return Service.query("locked") && NotificationScreen.lockscreenPreview;');
id = await evaluate('sms', `return await new Promise((resolve,reject)=>{
const n=new Notification('Vulpes — test local',{body:'Notification de qualification',tag:'vulpes-qualification',silent:true});
n.onshow=()=>resolve(n.id);n.onerror=()=>reject(n.error);
});`);
const selector = `[data-notification-id="${id}"]`;
result.tray = await wait('system', `return !!NotificationScreen.container.querySelector(${JSON.stringify(selector)});`);
result.lockscreen = await wait('system', `return !!NotificationScreen.getLockScreenContainer()?.querySelector(${JSON.stringify(selector)});`);
await evaluate('sms', `const rows=await Notification.get({tag:'vulpes-qualification'});rows.forEach(n=>n.close());return true;`);
result.removed = await wait('system', `return !NotificationScreen.container.querySelector(${JSON.stringify(selector)}) && !NotificationScreen.getLockScreenContainer()?.querySelector(${JSON.stringify(selector)});`);
result.passed = result.tray && result.lockscreen && result.removed;
} catch(error) { result.error=String(error); }
finally {
if(id) await evaluate('sms', `for(const n of await Notification.get({tag:'vulpes-qualification'})) n.close();return true;`).catch(()=>{});
win.document.getElementById('home').click();
}
await IOUtils.writeJSON(`${root}/logs/notifications.json`,result);
}
}
await requestedUiChecks();
let uiCheckRunning=false;
const uiCheckTimer=setInterval(async()=>{
if(uiCheckRunning) return;
uiCheckRunning=true;
try {
const reloadMarker=`${root}/logs/test-reload-gaia`;
if(await IOUtils.exists(reloadMarker)) {
await IOUtils.remove(reloadMarker);
win.document.querySelector('browser').reloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE);
await wait('system', "return document.body?.getAttribute('ready-state')==='fullyLoaded';");
await sleep(1500);
}
await requestedUiChecks();
} catch(error) {dump('TUNDRA_UI_CHECK_ERROR '+error+'\n');}
finally {uiCheckRunning=false;}
},3000);
win.addEventListener('unload',()=>clearInterval(uiCheckTimer),{once:true});
report.layout=await evaluate('homescreen',"return {width:innerWidth,height:innerHeight,dpr:devicePixelRatio,rootFont:getComputedStyle(document.documentElement).fontSize,bodyFont:getComputedStyle(document.body).fontFamily,zoom:visualViewport.scale,rootWidth:document.documentElement.scrollWidth};");
report.textFonts = await evaluate('homescreen', `
await document.fonts.ready;

View file

@ -0,0 +1,22 @@
{
"schema": 1,
"device": "sargo",
"scope": "Pinned hardware image inputs for local standalone builds; redistribution not reviewed",
"images": {
"vendor.img": {
"source": "vendor_a.img",
"sha256": "d584429873c88db741b81144b55977bbd8126b3e877445901bee6a0bda7fe4d0",
"bytes": 805306368
},
"modem.img": {
"source": "modem_a.img",
"sha256": "fd0624b37ac1d80c064a9763c7e2d2d6f94c3fd5e4617965b735ad411a473d0a",
"bytes": 115343360
}
},
"dtbo": {
"sha256": "0e43a57d6423e8d24cd944fcad0939ed81160e847a6d4203e2f7c16a86d69c03",
"bytes": 8417280
},
"vendorFingerprint": "google/sargo/sargo:9/PD2A.190115.029/5320088:user/release-keys"
}

View file

@ -20,7 +20,7 @@ NETWORK_SERVICES = frozenset(('irsc_util', 'vendor.per_mgr', 'vendor.rmt_storage
'vendor.tftp_server', 'cnss-daemon', 'pd_mapper'))
HARDWARE_SERVICES = frozenset(('vendor.qcrild', 'vendor.google.radioext@1.0',
HARDWARE_SERVICES = frozenset(('vendor.bluetooth-1-0', 'vendor.qcrild', 'vendor.google.radioext@1.0',
'vendor.audio-hal-2-0', 'vendor.camera-provider-2-4',
'camera_service', 'minimedia', 'minisf', 'miniaf'))

View file

@ -0,0 +1,38 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Acknowledge slot A only after this standalone installation has started Gaia."""
import json
from pathlib import Path
import subprocess
import time
def eligible(boot_id, gaia, mount, cmdline, services_ready):
return (cmdline.get('androidboot.slot_suffix') == '_a'
and mount.get('source') == '/dev/mmcblk0p72'
and mount.get('fstype') == 'ext4'
and gaia.get('bootId') == boot_id and gaia.get('passed') is True
and bool(gaia.get('checks')) and all(gaia['checks'].values())
and services_ready)
def main():
cmdline=dict(x.split('=',1) for x in Path('/proc/cmdline').read_text().split() if '=' in x)
boot_id=Path('/proc/sys/kernel/random/boot_id').read_text().strip()
mounts=json.loads(subprocess.check_output(['findmnt','--json','--mountpoint','/reference/tundra-volume','--output','SOURCE,FSTYPE'],text=True))['filesystems']
if len(mounts)!=1 or mounts[0]['source']!='/dev/mmcblk0p72':
return # Container/RAM experiments must not mark a flashed slot successful.
for _ in range(120):
try:
gaia=json.loads(Path('/opt/vulpes/logs/test.json').read_text())
ready=subprocess.run(['systemctl','is-active','--quiet','tundra-hal','tundra-display','tundra-radio']).returncode==0
if eligible(boot_id,gaia,mounts[0],cmdline,ready):break
except (OSError,ValueError):pass
time.sleep(5)
else:raise RuntimeError('Gaia did not pass startup; slot success left unchanged')
subprocess.run(['/usr/libexec/tundra-qbootctl','-m','a'],check=True)
Path('/var/lib/tundra').mkdir(parents=True,exist_ok=True)
Path('/var/lib/tundra/boot-success.json').write_text(json.dumps({'bootId':boot_id,'gaiaPassed':True,'slot':'a'})+'\n')
if __name__=='__main__':main()

View file

@ -0,0 +1,34 @@
# SPDX-License-Identifier: MPL-2.0
"""Connect the Sargo Bluetooth HAL to BlueZ; pairing stays user initiated."""
def prepare(root, write):
write('etc/dbus-1/system.d/tundra-bluetooth.conf', '''<!DOCTYPE busconfig PUBLIC
"-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig><policy user="root"><allow own="org.bluez"/><allow send_destination="org.bluez"/></policy></busconfig>
''')
write('etc/bluetooth/main.conf', '[General]\nName=Vulpes OS\nDiscoverableTimeout=120\n[Policy]\nAutoEnable=false\n')
write('etc/systemd/system/tundra-bluebinder.service', '''[Unit]
Description=Sargo Bluetooth HAL bridge
Requires=tundra-hal.service
After=tundra-hal.service
Before=bluetooth.service
[Service]
Type=notify
ExecStartPre=/usr/bin/binder-wait android.hardware.bluetooth@1.0::IBluetoothHci/default
ExecStart=/usr/sbin/bluebinder
Restart=on-failure
RestartSec=5
TimeoutStartSec=60
NoNewPrivileges=yes
ProtectHome=yes
ProtectSystem=strict
PrivateTmp=yes
''')
write('etc/systemd/system/bluetooth.service.d/tundra.conf', '''[Unit]
Requires=tundra-bluebinder.service dbus.service
After=tundra-bluebinder.service dbus.service
''')
wants = root/'etc/systemd/system/tundra.target.wants'
wants.mkdir(parents=True, exist_ok=True)
(wants/'bluetooth.service').symlink_to('/usr/lib/systemd/system/bluetooth.service')

View file

@ -3,10 +3,13 @@
"""Configure isolated oFono/PulseAudio diagnostics; no call or SMS on startup."""
from pathlib import Path
import shutil
import runpy
def prepare(root, write):
write('etc/tundra/hardware-enabled', 'sargo\n')
runpy.run_path(str(Path(__file__).with_name('runtime-packages.py')))['stage'](root)
runpy.run_path(str(Path(__file__).with_name('prepare-bluetooth.py')))['prepare'](root, write)
shutil.copy2(Path(__file__).with_name('modem-recovery.py'),
root/'etc/tundra/modem-recovery.py')
write('etc/systemd/system/tundra-hal.service.d/modem-recovery.conf', '''[Service]

View file

@ -0,0 +1,82 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Stage pinned package payloads missing from the original ARM64 reference."""
import hashlib
import io
import json
from pathlib import Path
import shutil
import subprocess
import tarfile
import tempfile
import urllib.request
PACKAGES = (
('libdw1t64_0.196-1_arm64.deb', 'e/elfutils', 'a82634454de2b28d73374f693e4ab6c1c2634fa53b0139312c5f439c294dad2b'),
('libelf1t64_0.196-1_arm64.deb', 'e/elfutils', 'ead8a1244824d982ce51fb82be4904312d5dc44ddc157e589def58c257738476'),
('bluez_5.87-4_arm64.deb', 'b/bluez', 'fe733034dbdfa6146fc94112ad8ca0fce71d8e01d22a59ddb49ef2df3777d6b5'),
('ca-certificates_20260816_all.deb', 'c/ca-certificates', '1cb8b73c74c47677b329fad4095fb9370227d4b8f1969372411478a4586c46cf'),
)
# Existing libelf 0.194-4 files in the inventoried runtime; reject other replacements.
REPLACES = {'usr/lib/aarch64-linux-gnu/libelf-0.194.so': 'ade23632ea253cb4ecfe95d5436bfeb3d40ae2003c5fe10bf1ac1c5b30bdf2e2', 'usr/share/doc/libelf1t64/changelog.Debian.gz': 'ffb0ea69fdbf6f32053059985048676931fd7d5c4458f8ca4bc54750cf4b1b31', 'usr/share/doc/libelf1t64/changelog.gz': '2e8e0af5c39ab744ac6934fe07419b5fd5504a50cd2f42110dd04315c548a708', 'usr/share/doc/libelf1t64/copyright': '2409860c86675ab33f8ad22480608faf849cbc2559d6478f708c23c508a1dbf2', 'usr/share/locale/de/LC_MESSAGES/elfutils.mo': 'f72328e130e63f231acb8e1a56ce5f4f3e91d0fc290db95d7ba4e4b2448d4d93', 'usr/share/locale/en@boldquot/LC_MESSAGES/elfutils.mo': '90158a24e3f85aaacc64085ef2644ba19075cb847c754341c9f245aaa69e94c6', 'usr/share/locale/en@quot/LC_MESSAGES/elfutils.mo': '33c6cda39c2145463dfdb2eb849922dbf6ccf51a9e4bfa8fd757744712d9abfa', 'usr/share/locale/es/LC_MESSAGES/elfutils.mo': '1ebff7e5583759b3f08eb5caaf18dd1a6ed6eb5ec71a02502ed8e21b134ad54f', 'usr/share/locale/ja/LC_MESSAGES/elfutils.mo': '764ed859f3aebd861da0b73c69da4d72d0f252952bb3be1c9c66360258a1661c', 'usr/share/locale/pl/LC_MESSAGES/elfutils.mo': '8abb000f43024994a9ec3032b52eb10858e004b81bc61c7ceea96f7467d13fdd', 'usr/share/locale/uk/LC_MESSAGES/elfutils.mo': 'ce0d747f478ee91a1cdef66bfd1e230a3ff1e787358f4c6e1ffb6513df713117'}
def stage(root, cache=None):
cache = cache or Path(__file__).resolve().parents[1]/'downloads/native-packages-20261008'
cache.mkdir(parents=True, exist_ok=True)
records = []
for name, directory, sha in PACKAGES:
url = 'https://deb.debian.org/debian/pool/main/'+directory+'/'+name
package = cache/name
if not package.exists():
with urllib.request.urlopen(url, timeout=60) as response:
data = response.read()
if hashlib.sha256(data).hexdigest() != sha:
raise ValueError('Package checksum mismatch: '+name)
package.write_bytes(data)
if hashlib.sha256(package.read_bytes()).hexdigest() != sha:
raise ValueError('Package checksum mismatch: '+name)
members = subprocess.check_output(['ar', 't', str(package)], text=True).splitlines()
member = next(m for m in members if m.startswith('data.tar'))
payload = subprocess.check_output(['ar', 'p', str(package), member])
with tempfile.TemporaryDirectory() as folder:
source = Path(folder)
with tarfile.open(fileobj=io.BytesIO(payload)) as archive:
archive.extractall(source, filter='data')
if name.startswith('ca-certificates'):
certificates = sorted((source/'usr/share/ca-certificates/mozilla').glob('*.crt'))
if len(certificates) < 50:
raise ValueError('Incomplete CA bundle')
target = root/'etc/ssl/certs/ca-certificates.crt'
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(b'\n'.join(p.read_bytes() for p in certificates)+b'\n')
target.chmod(0o644)
cert_link = root/'usr/lib/ssl/cert.pem'
cert_link.parent.mkdir(parents=True, exist_ok=True)
if not cert_link.exists() and not cert_link.is_symlink():
cert_link.symlink_to('../../../etc/ssl/certs/ca-certificates.crt')
selected = [source/'usr/share/doc/ca-certificates/copyright']
else:
selected = [p for p in (source/'usr').rglob('*') if p.is_file() or p.is_symlink()]
for path in selected:
target = root/path.relative_to(source)
target.parent.mkdir(parents=True, exist_ok=True)
if path.is_symlink():
if target.exists() or target.is_symlink():
if not target.is_symlink() or target.readlink() != path.readlink():
raise ValueError('Conflicting package link: '+str(target))
else:
target.symlink_to(path.readlink())
else:
if target.exists() and target.read_bytes() != path.read_bytes():
expected = REPLACES.get(str(path.relative_to(source)))
if not expected or hashlib.sha256(target.read_bytes()).hexdigest() != expected:
raise ValueError('Conflicting package file: '+str(target))
shutil.copyfile(path, target)
target.chmod(path.stat().st_mode & 0o777)
records.append({'file': name, 'url': url, 'sha256': sha})
record = root/'usr/share/vulpes/runtime-packages.json'
record.parent.mkdir(parents=True, exist_ok=True)
record.write_text(json.dumps(records, indent=2)+'\n')
return records

View file

@ -53,7 +53,19 @@ def main():
with image.open('rb') as f:sha=hashlib.file_digest(f,'sha256').hexdigest()
if sha!='132d0e18c6a6a111293447e940267f35aa2b14fa00a872ef160b20dc2014d886':raise RuntimeError('Android reference changed')
run('mount','-t','ext4','-o','loop,ro,noload',str(image),'/run/tundra/android-original')
run('mount','-t','ext4','-o','ro,noload','/dev/mmcblk0p70','/run/tundra/vendor-original')
hardware = Path('/etc/tundra/hardware-images.json')
bundled = json.loads(hardware.read_text())['images'] if hardware.exists() else None
if bundled:
if set(bundled) != {'vendor.img', 'modem.img'}:
raise RuntimeError('Expected both standalone hardware images')
for name, record in bundled.items():
path = Path('/reference')/name
with path.open('rb') as source:
if path.stat().st_size != record['bytes'] or hashlib.file_digest(source, 'sha256').hexdigest() != record['sha256']:
raise RuntimeError('Standalone hardware image changed: '+name)
run('mount','-t','ext4','-o','loop,ro,noload','/reference/vendor.img','/run/tundra/vendor-original')
else:
run('mount','-t','ext4','-o','ro,noload','/dev/mmcblk0p70','/run/tundra/vendor-original')
run('mount','-t','ext4','-o','ro,noload','/dev/mmcblk0p48','/mnt/vendor/persist')
overlay('/run/tundra/android-original','/android','android')
overlay('/run/tundra/vendor-original','/android/vendor','vendor')
@ -65,7 +77,10 @@ def main():
if count != 1:raise RuntimeError('Expected exactly one Sargo WLAN IPA setting')
config.write_text(text) # vendor overlay upper is tmpfs, never the partition
directory('/android/vendor/firmware_mnt')
run('mount','-t','vfat','-o','ro','/dev/mmcblk0p21','/android/vendor/firmware_mnt')
if bundled:
run('mount','-t','vfat','-o','loop,ro','/reference/modem.img','/android/vendor/firmware_mnt')
else:
run('mount','-t','vfat','-o','ro','/dev/mmcblk0p21','/android/vendor/firmware_mnt')
report={}
for p in sorted(Path('/android').rglob('*.rc')):
if not p.is_file() or p.is_symlink() or not is_init_script(p):continue

View file

@ -50,7 +50,11 @@ class HardwareTests(unittest.TestCase):
self.assertIn('unmanaged-devices=*,except:interface-name:wlan0', config)
self.assertIn('auth-polkit=root-only', config)
self.assertIn('enabled=false', config)
self.assertFalse((root/'etc/NetworkManager/system-connections').exists())
connections = root/'etc/NetworkManager/system-connections'
self.assertTrue(connections.is_dir())
self.assertEqual(list(connections.iterdir()), [])
wifi_service = (root/'etc/systemd/system/tundra-wifi.service').read_text()
self.assertIn('ReadWritePaths=/run /etc/NetworkManager/system-connections', wifi_service)
self.assertIn('VULPES_TUNDRA=1', (root/'etc/systemd/system/tundra-display.service').read_text())

View file

@ -0,0 +1,47 @@
"""Check the pinned Bluetooth libraries and native TLS trust store offline."""
import importlib.util
import json
from pathlib import Path
import ssl
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('packages', ROOT/'runtime/runtime-packages.py')
packages = importlib.util.module_from_spec(spec)
spec.loader.exec_module(packages)
CACHE = ROOT/'downloads/native-packages-20261008'
class RuntimePackages(unittest.TestCase):
def test_corrupt_archive_is_rejected_before_extraction(self):
with tempfile.TemporaryDirectory() as folder:
base = Path(folder)
cache = base/'cache'
cache.mkdir()
(cache/packages.PACKAGES[0][0]).write_bytes(b'corrupt')
with self.assertRaisesRegex(ValueError, 'checksum mismatch'):
packages.stage(base/'root', cache)
self.assertFalse((base/'root').exists())
@unittest.skipUnless(all((CACHE/name).exists() for name, _, _ in packages.PACKAGES),
'Pinned package cache is not installed')
def test_staged_payload_is_repeatable_and_certificates_are_usable(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
first = packages.stage(root, CACHE)
self.assertEqual(packages.stage(root, CACHE), first)
self.assertEqual(json.loads((root/'usr/share/vulpes/runtime-packages.json').read_text()), first)
self.assertTrue((root/'usr/libexec/bluetooth/bluetoothd').is_file())
context = ssl.create_default_context(cafile=str(root/'etc/ssl/certs/ca-certificates.crt'))
self.assertGreater(context.cert_store_stats()['x509_ca'], 50)
self.assertTrue((root/'usr/lib/ssl/cert.pem').is_file())
target = root/'usr/libexec/bluetooth/bluetoothd'
target.write_bytes(b'unknown local binary')
with self.assertRaisesRegex(ValueError, 'Conflicting package file'):
packages.stage(root, CACHE)
self.assertEqual(target.read_bytes(), b'unknown local binary')
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,37 @@
# SPDX-License-Identifier: MPL-2.0
import io
from pathlib import Path
import struct
import sys
import unittest
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools'))
from sparse_backup import receive
def record(offset,data):return struct.pack('<QQ',offset,len(data))+data
class BackupTests(unittest.TestCase):
def test_holes_and_final_size_are_restored(self):
stream=b'TUNRAW1\0'+record(2,b'abc')+record(9,b'x')+record(12,b'')
target=io.BytesIO();receive(io.BytesIO(stream),target,12)
# BytesIO does not extend on truncate; actual disk files do.
self.assertEqual(target.getvalue(),b'\0\0abc\0\0\0\0x')
def test_empty_disk_has_a_required_trailer(self):
receive(io.BytesIO(b'TUNRAW1\0'+record(100,b'')),io.BytesIO(),100)
with self.assertRaises(ValueError):receive(io.BytesIO(b'TUNRAW1\0'),io.BytesIO(),100)
def test_overlapping_or_out_of_bounds_blocks_rejected(self):
for records in (record(2,b'abc')+record(4,b'bad'),record(8,b'too long')):
with self.assertRaises(ValueError):receive(io.BytesIO(b'TUNRAW1\0'+records),io.BytesIO(),10)
def test_wrong_trailer_and_trailing_bytes_rejected(self):
for stream in (b'TUNRAW1\0'+record(9,b''),b'TUNRAW1\0'+record(10,b'')+b'extra'):
with self.assertRaises(ValueError):receive(io.BytesIO(stream),io.BytesIO(),10)
def test_truncated_payload_rejected(self):
with self.assertRaises(ValueError):receive(io.BytesIO(b'TUNRAW1\0'+struct.pack('<QQ',0,5)+b'four'),io.BytesIO(),10)
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,91 @@
# SPDX-License-Identifier: MPL-2.0
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
TOOLS=Path(__file__).resolve().parents[1]/'tools'
sys.path.insert(0,str(TOOLS))
from standalone_volume import checked_volume
spec=importlib.util.spec_from_file_location('standalone_boot',TOOLS/'build-standalone-boot.py')
boot=importlib.util.module_from_spec(spec);spec.loader.exec_module(boot)
class StandaloneBootTests(unittest.TestCase):
def setUp(self):
self.volume={'schema':1,'device':'sargo','storage':'raw-userdata-ext4','image':'userdata.ext4',
'uuid':'12345678-1234-1234-1234-123456789abc','bytes':8*1024**3,
'imageSha256':'a'*64,'systemImageSha256':'b'*64,'filesystemCheckPassed':True}
self.init=b'''EXPECTED_STORAGE_HASH=old
# Use the inventoried filesystem
mount /dev/mapper/tundra-installed-ro /installed
echo TUNDRA_RESCUE_READY
exec /bin/sh /native-root.sh
'''
self.native=b'''SYSTEM_IMAGE=/installed/tundra/images/system.ext4
LOWER=/installed/home/droidian/vulpes-tundra-tests/runtime-reference-20260925/root
mkdir -p /newroot/reference/installed
mount --move /installed /newroot/reference/installed
'''
def test_direct_boot_has_no_legacy_storage_path(self):
init,native,mount=boot.storage_scripts(self.init,self.native,self.volume,False)
self.assertNotIn(b'EXPECTED_STORAGE_HASH',init)
self.assertNotIn(b'/dev/mapper',init)
self.assertNotIn(b'/installed',native)
self.assertNotIn(b'/home/droidian',native)
self.assertIn(b'/dev/mmcblk0p72',mount)
self.assertIn(b'VOLUME_OPTIONS=rw',mount)
self.assertNotIn(b'mkfs',mount)
self.assertIn(b'/newroot/reference/tundra-volume',native)
def test_container_boot_changes_only_volume_access(self):
init,native,mount=boot.storage_scripts(self.init,self.native,self.volume,True)
self.assertIn(b'/run/legacy-storage',init)
self.assertIn(b'VOLUME_OPTIONS=loop,rw',mount)
self.assertNotIn(b'/dev/mmcblk0p72',mount)
self.assertIn(b'/volume/tundra/images',native)
for script in (init,native,mount):
self.assertEqual(subprocess.run(['sh','-n'],input=script,capture_output=True).returncode,0)
def test_bundled_hardware_is_bound_read_only(self):
volume={**self.volume,'hardwareImages':{'vendor.img':{'sha256':'a'*64,'bytes':4096},'modem.img':{'sha256':'b'*64,'bytes':4096}}}
native=self.native+b'# Installed lower layers stay reachable\n'
_,script,_=boot.storage_scripts(self.init,native,volume,False)
self.assertIn(b'mount -o remount,bind,ro /newroot/reference/vendor.img',script)
self.assertIn(b'/newroot/etc/tundra/hardware-images.json',script)
self.assertEqual(subprocess.run(['sh','-n'],input=script,capture_output=True).returncode,0)
def test_wrong_volume_metadata_rejected(self):
with tempfile.TemporaryDirectory() as directory:
path=Path(directory)/'volume.json'
path.write_text(json.dumps(self.volume));checked_volume(path,False)
for key,value in [('device','bonito'),('image','../userdata.ext4'),('bytes',1),('uuid','$(reboot)'),('storage','lvm'),('filesystemCheckPassed',False)]:
path.write_text(json.dumps({**self.volume,key:value}))
with self.assertRaises(ValueError):checked_volume(path,False)
def test_boot_success_requires_current_gaia_on_raw_userdata(self):
spec=importlib.util.spec_from_file_location('success',TOOLS.parent/'runtime/mark-boot-success.py')
success=importlib.util.module_from_spec(spec);spec.loader.exec_module(success)
gaia={'bootId':'current','passed':True,'checks':{'home':True}}
mount={'source':'/dev/mmcblk0p72','fstype':'ext4'}
self.assertTrue(success.eligible('current',gaia,mount,{'androidboot.slot_suffix':'_a'},True))
self.assertFalse(success.eligible('new',gaia,mount,{'androidboot.slot_suffix':'_a'},True))
self.assertFalse(success.eligible('current',gaia,{'source':'/dev/loop1','fstype':'ext4'},{'androidboot.slot_suffix':'_a'},True))
self.assertFalse(success.eligible('current',gaia,mount,{'androidboot.slot_suffix':'_b'},True))
self.assertFalse(success.eligible('current',gaia,mount,{'androidboot.slot_suffix':'_a'},False))
def test_old_installer_refuses_standalone_image(self):
spec=importlib.util.spec_from_file_location('install',TOOLS/'install-sargo.py')
install=importlib.util.module_from_spec(spec);spec.loader.exec_module(install)
with tempfile.TemporaryDirectory() as directory:
path=Path(directory);(path/'build.json').write_text(json.dumps({'storageMode':'raw-userdata-ext4'}))
with self.assertRaisesRegex(ValueError,'standalone installer'):
install.checked_build(path)
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,80 @@
# SPDX-License-Identifier: MPL-2.0
import importlib.util
from pathlib import Path
import struct
import sys
import tempfile
import unittest
from unittest.mock import patch
spec=importlib.util.spec_from_file_location('flash',Path(__file__).resolve().parents[1]/'tools/flash-standalone-sargo.py')
flash=importlib.util.module_from_spec(spec);spec.loader.exec_module(flash)
class FlashTests(unittest.TestCase):
def plan(self):
return {'userdataBytes':8*1024**3,'partitions':{name:{'bytes':4096} for name in flash.PARTITIONS}}
def values(self,**changes):
values={'product':'sargo','unlocked':'yes','current-slot':'b',
**{'partition-size:'+name:hex(64*1024**3 if name=='userdata' else 64*1024**2) for name in flash.PARTITIONS}}
values.update(changes);return values
def test_both_slots_accepted_for_full_install(self):
for slot in ('a','b'):
values=self.values(**{'current-slot':slot})
with patch.object(flash,'getvar',side_effect=lambda _,__,name:values[name]):
self.assertEqual(flash.preflight('fastboot','serial',self.plan())['current-slot'],slot)
def test_wrong_model_locked_or_small_partition_rejected(self):
for change in ({'product':'bonito'},{'unlocked':'no'},{'partition-size:userdata':'0x1000'}):
values=self.values(**change)
with patch.object(flash,'getvar',side_effect=lambda _,__,name:values[name]):
with self.assertRaises(ValueError):flash.preflight('fastboot','serial',self.plan())
def test_review_never_contacts_device(self):
with patch.object(sys,'argv',['flash']),patch.object(flash,'package',return_value=self.plan()),patch.object(flash.subprocess,'run') as run,patch.object(flash.subprocess,'check_output') as read:
flash.main();run.assert_not_called();read.assert_not_called()
def test_install_requires_explicit_userdata_replacement(self):
with patch.object(sys,'argv',['flash','--install']),patch.object(flash,'package',return_value=self.plan()),patch.object(flash.subprocess,'run') as run:
with self.assertRaises(SystemExit):flash.main()
run.assert_not_called()
def test_unqualified_package_requires_experimental_flag(self):
with patch.object(sys,'argv',['flash','--install','--erase-userdata']),patch.object(flash,'package',return_value=self.plan()),patch.object(flash.subprocess,'run') as run:
with self.assertRaises(SystemExit):flash.main()
run.assert_not_called()
def test_sparse_geometry(self):
with tempfile.TemporaryDirectory() as directory:
image=Path(directory)/'userdata.img'
image.write_bytes(struct.pack('<I4H4I',0xed26ff3a,1,0,28,12,4096,2097152,3,0))
self.assertEqual(flash.sparse_size(image),8*1024**3)
image.write_bytes(b'bad')
with self.assertRaises(ValueError):flash.sparse_size(image)
def test_incomplete_package_is_rejected_before_manifest_read(self):
with tempfile.TemporaryDirectory() as directory:
(Path(directory)/'.incomplete').touch()
with self.assertRaisesRegex(ValueError,'construction is incomplete'):
flash.package(directory)
def test_install_writes_only_planned_partitions_after_preflight(self):
with tempfile.TemporaryDirectory() as directory:
argv=['flash','--package',directory,'--install','--erase-userdata','--experimental']
with patch.object(sys,'argv',argv),patch.object(flash,'package',return_value=self.plan()), \
patch.object(flash.shutil,'which',return_value='fastboot'), \
patch.object(flash.subprocess,'check_output',return_value='test-device fastboot\n'), \
patch.object(flash,'preflight',return_value={'product':'sargo'}) as preflight, \
patch.object(flash.subprocess,'run') as run:
flash.main()
preflight.assert_called_once()
commands=[call.args[0][3:] for call in run.call_args_list]
self.assertEqual(commands[0],['erase','userdata'])
self.assertEqual([command[:2] for command in commands[1:-1]],
[['flash',partition] for partition in flash.PARTITIONS])
self.assertEqual(commands[-1],['set_active','a'])
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,69 @@
# SPDX-License-Identifier: MPL-2.0
import gzip
import hashlib
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location('restore', Path(__file__).resolve().parents[1]/'tools/restore-standalone-sargo.py')
restore = importlib.util.module_from_spec(spec)
spec.loader.exec_module(restore)
class RestoreTests(unittest.TestCase):
def make_backup(self, folder):
payload = b'original userdata\0' * 100
sha = hashlib.sha256(payload).hexdigest()
data = {'archive': 'userdata-original.raw.gz', 'bytes': len(payload),
'sha256': sha, 'checked': True, 'sourceWasUnmounted': True}
(folder/data['archive']).write_bytes(gzip.compress(payload))
(folder/'userdata.json').write_text(json.dumps(data))
files = {}
for name in ('boot_a.img', 'dtbo_a.img', 'vbmeta_a.img'):
content = name.encode()
(folder/name).write_bytes(content)
files[name] = {'bytes': len(content), 'sha256': hashlib.sha256(content).hexdigest()}
manifest = {'device': 'sargo', 'activeSlot': 'a', 'files': files,
'userdataSha256': sha, 'userdataBytes': len(payload)}
(folder/'recovery.json').write_text(json.dumps(manifest))
return manifest
def test_review_verifies_backup_without_phone_commands(self):
with tempfile.TemporaryDirectory() as directory:
folder = Path(directory)
self.make_backup(folder)
with patch.object(sys, 'argv', ['restore', '--backup', directory]), \
patch.object(restore.subprocess, 'run') as run, \
patch.object(restore.subprocess, 'check_output') as read:
restore.main()
run.assert_not_called()
read.assert_not_called()
def test_unbound_userdata_is_rejected(self):
with tempfile.TemporaryDirectory() as directory:
folder = Path(directory)
manifest = self.make_backup(folder)
manifest['userdataSha256'] = '0' * 64
(folder/'recovery.json').write_text(json.dumps(manifest))
with patch.object(sys, 'argv', ['restore', '--backup', directory]), \
patch.object(restore.subprocess, 'run') as run:
with self.assertRaisesRegex(ValueError, 'not bound'):
restore.main()
run.assert_not_called()
def test_altered_userdata_is_rejected(self):
with tempfile.TemporaryDirectory() as directory:
folder = Path(directory)
self.make_backup(folder)
(folder/'userdata-original.raw.gz').write_bytes(gzip.compress(b'changed'))
with patch.object(sys, 'argv', ['restore', '--backup', directory]):
with self.assertRaisesRegex(ValueError, 'Storage backup changed'):
restore.main()
if __name__ == '__main__':
unittest.main()

View file

@ -184,9 +184,19 @@ def main():
buffer = io.BytesIO()
selected = [*sorted((ROOT.parent/'host').rglob('*')),
*sorted((ROOT.parent/'services').rglob('*.mjs')),
*sorted((ROOT.parent/'assets/webapps/marketplace.gaiamobile.org').rglob('*')),
ROOT.parent/'tools/installed_apps.py',
*sorted((ROOT.parent/'gaia/shared/elements/vulpes-fonts').rglob('*')),
*sorted((ROOT.parent/'overrides').rglob('*')),
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/views/form.js',
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/contacts.js',
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/activities.js',
ROOT.parent/'assets/webapps/search.gaiamobile.org/manifest.webapp',
ROOT.parent/'assets/webapps/homescreen.gaiamobile.org/gaia_build_defer_index.js',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/js/tone_player.js',
ROOT.parent/'tools/project.py',
ROOT.parent/'tools/serve-gaia.py',
ROOT.parent/'release.json', ROOT.parent/'tests/ProbeChild.sys.mjs',
ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py']

View file

@ -0,0 +1,146 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Build a raw-userdata Tundra boot, or its nondestructive container test variant."""
import argparse
import json
import os
from pathlib import Path
import shlex
import shutil
import stat
import subprocess
import sys
import tempfile
from ramdisk_reference import read_newc
from standalone_volume import checked_volume
from tundra import ROOT, checked_source, cpio, digest
def storage_scripts(init, native, volume, trial):
start = init.index(b'# Use the inventoried filesystem')
end = init.index(b'echo TUNDRA_RESCUE_READY', start)
legacy = init[start:end]
# The container test keeps the old mount solely as a carrier for the image.
if trial:
legacy += b'mkdir -p /run/legacy-storage\nmount --move /installed /run/legacy-storage\n'
else:
legacy = b'# Standalone boot does not open the previous operating system.\n'
init = init[:start]+legacy+init[end:]
init = init.replace(b'exec /bin/sh /native-root.sh', b'exec /bin/sh /standalone-mount.sh')
native = native.replace(b'LOWER=/installed/home/droidian/vulpes-tundra-tests/runtime-reference-20260925/root',
b': "${SYSTEM_IMAGE:?A bundled system image is required}"\nLOWER=/run/tundra-system')
native = native.replace(b'/installed', b'/volume')
native = native.replace(b'/reference/volume', b'/reference/tundra-volume')
if volume.get('hardwareImages'):
lines = ['mkdir -p /newroot/reference']
for name in ('vendor.img', 'modem.img'):
lines += ['test -s /volume/tundra/hardware/'+name,
': > /newroot/reference/'+name,
'mount --bind /volume/tundra/hardware/'+name+' /newroot/reference/'+name,
'mount -o remount,bind,ro /newroot/reference/'+name]
config = json.dumps({'images':volume['hardwareImages']}, separators=(',',':'))
lines += ['mkdir -p /newroot/etc/tundra',
'printf \'%s\\n\' '+shlex.quote(config)+' > /newroot/etc/tundra/hardware-images.json']
native = native.replace(b'# Installed lower layers', ('\n'.join(lines)+'\n# Installed lower layers').encode())
if not trial:
if b'/dev/mapper' in init or b'EXPECTED_STORAGE_HASH' in init:
# The old expected hash assignment is unnecessary in the raw image.
init = b'\n'.join(line for line in init.split(b'\n') if not line.startswith(b'EXPECTED_STORAGE_HASH='))
if b'/dev/mapper' in init or b'/installed/' in native:
raise ValueError('Legacy storage leaked into standalone boot')
device = '/run/legacy-storage/tundra/standalone/'+volume['uuid']+'.ext4' if trial else '/dev/mmcblk0p72'
config = 'VOLUME_DEVICE='+shlex.quote(device)+'\nVOLUME_UUID='+shlex.quote(volume['uuid'])+'\nVOLUME_OPTIONS='+('loop,rw' if trial else 'rw')+'\n'
mount = (ROOT/'boot/standalone-mount.sh').read_bytes().replace(b'# VOLUME_CONFIGURATION', config.encode())
return init, native, mount
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--volume', type=Path, required=True)
parser.add_argument('--prepared', type=Path, required=True)
parser.add_argument('--device-workspace', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--container-trial', action='store_true')
parser.add_argument('--no-deadline', action='store_true')
parser.add_argument('--boot-control', type=Path, help='Pinned ARM64 qbootctl binary')
parser.add_argument('--no-maintenance', action='store_true', help='Exclude private USB SSH credentials and server')
parser.add_argument('--local-debugger', action='store_true', help='Private RAM tests: loopback Marionette, never a public image')
args = parser.parse_args(); os.umask(0o077)
if args.local_debugger and args.no_maintenance: parser.error('Local debugger requires a private maintenance image')
volume = checked_volume(args.volume)
if args.no_deadline and not args.container_trial and not args.boot_control:
parser.error('Persistent standalone images require --boot-control')
if args.boot_control and digest(args.boot_control) != 'dcb00e4c4f038071a5d9979263128325e16b848903371f6c00d49cf3e25c34d9':
raise ValueError('Unqualified boot-control binary')
if args.boot_control and digest(args.boot_control.with_name('copyright')) != '8969e659aff7ed5bb3249341764c2bd048d5abe7400076f01485650e727e8c42':
raise ValueError('Boot-control license notice missing or changed')
system = json.loads((args.prepared/'system.json').read_text())
state = json.loads((args.prepared/'data/state.json').read_text())
if volume['systemImageSha256'] != system['imageSha256'] or volume['stateUuid'] != state['uuid']:
raise ValueError('Volume does not contain this system/data pair')
command = [sys.executable,str(ROOT/'tools/build-rescue-boot.py'), '--output',str(args.output),
'--device-workspace',str(args.device_workspace),'--layout',str(args.device_workspace/'layout.json'),
'--native','--runtime-config','--network','--hardware',
'--system-image',str(args.prepared/'system.json'),'--state',str(args.prepared/'data/state.json')]
if args.no_deadline: command.append('--no-deadline')
if args.local_debugger: command.append('--local-debugger')
subprocess.run(command,check=True)
out = args.output.resolve(); build = json.loads((out/'build.json').read_text())
ramdisk = out/'initramfs.cpio.gz'; files = read_newc(ramdisk.read_bytes())
init,native,mount = storage_scripts(files['init']['data'],files['native-root.sh']['data'],volume,args.container_trial)
if args.boot_control:
files['sbin/tundra-qbootctl']={'mode':stat.S_IFREG|0o755,'data':args.boot_control.read_bytes()}
files['qbootctl-copyright']={'mode':stat.S_IFREG|0o644,'data':args.boot_control.with_name('copyright').read_bytes()}
files['standalone-boot-success.py']={'mode':stat.S_IFREG|0o755,'data':(ROOT/'runtime/mark-boot-success.py').read_bytes()}
unit=b'[Unit]\nDescription=Acknowledge a healthy standalone Vulpes boot\nAfter=tundra-display.service\n[Service]\nType=oneshot\nTimeoutStartSec=650\nExecStart=/usr/bin/python3 /usr/libexec/tundra-mark-boot-success.py\n'
files['standalone-boot-success.service']={'mode':stat.S_IFREG|0o644,'data':unit}
setup=b"""mkdir -p /newroot/usr/libexec /newroot/etc/systemd/system/tundra.target.wants
mkdir -p /newroot/usr/share/doc/qbootctl
cp /qbootctl-copyright /newroot/usr/share/doc/qbootctl/copyright
cp /sbin/tundra-qbootctl /newroot/usr/libexec/tundra-qbootctl
cp /standalone-boot-success.py /newroot/usr/libexec/tundra-mark-boot-success.py
cp /standalone-boot-success.service /newroot/etc/systemd/system/tundra-boot-success.service
ln -sf ../tundra-boot-success.service /newroot/etc/systemd/system/tundra.target.wants/tundra-boot-success.service
"""
native=native.replace(b'# Installed lower layers',setup+b'# Installed lower layers')
if args.no_maintenance:
init=b'\n'.join(line for line in init.split(b'\n') if not line.startswith(b'/sbin/dropbear '))
init=init.replace(b'TUNDRA_USB_SSH_READY',b'TUNDRA_USB_READY')
start=native.index(b'# Keep a minimal, already tested rescue environment')
end=native.index(b'for fs in dev proc sys run;',start)
native=native[:start]+b'# Public boot: no USB maintenance account or shared SSH key.\n'+native[end:]
for name in ('etc/tundra-hostkey','root/.ssh/authorized_keys','sbin/dropbear'):
files.pop(name,None)
files['init']['data']=init; files['native-root.sh']['data']=native
files['standalone-mount.sh']={'mode':stat.S_IFREG|0o755,'data':mount}
files['volume']={'mode':stat.S_IFDIR|0o755,'data':b''}
entries=[]
for name, item in sorted(files.items()):
entries.append((name,item['mode'],item['data'],5 if name=='dev/console' else 0,1 if name=='dev/console' else 0))
ramdisk.write_bytes(cpio(entries))
packager=checked_source('aosp-mkbootimg'); original=out/build['image']
with tempfile.TemporaryDirectory() as temp:
unpack=Path(temp)/'unpack'
text=subprocess.check_output([sys.executable,str(packager/'unpack_bootimg.py'),'--boot_img',str(original),'--out',str(unpack),'--format=mkbootimg'],text=True)
options=shlex.split(text); fields=dict(zip(options[::2],options[1::2]));fields['--ramdisk']=str(ramdisk)
image=out/'boot.img'; command=[sys.executable,str(packager/'mkbootimg.py')]
for key,value in fields.items():command.extend([key,value])
subprocess.run(command+['-o',str(image)],check=True)
verify=Path(temp)/'verify'
subprocess.run([sys.executable,str(packager/'unpack_bootimg.py'),'--boot_img',str(image),'--out',str(verify)],check=True,stdout=subprocess.DEVNULL)
if digest(verify/'kernel')!=build['kernelSha256'] or digest(verify/'ramdisk')!=digest(ramdisk):
raise ValueError('Standalone boot roundtrip failed')
original.unlink()
for path in (Path(__file__).resolve(),ROOT/'tools/standalone_volume.py',ROOT/'boot/standalone-mount.sh',ROOT/'runtime/mark-boot-success.py'):
relative=str(path.relative_to(ROOT)); target=out/'sources/tundra'/relative
target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(path,target);build['sources'][relative]=digest(path)
build.update(image='boot.img',imageSha256=digest(image),ramdiskSha256=digest(ramdisk),
storageMode='container-test' if args.container_trial else 'raw-userdata-ext4',
standaloneVolume=volume,flashReady=False,bootControlSha256=digest(args.boot_control) if args.boot_control else None,
containsPrivateTrialHostKey=not args.no_maintenance,maintenanceEnabled=not args.no_maintenance,
scope='Standalone Tundra boot candidate; hardware qualification required')
(out/'build.json').write_text(json.dumps(build,indent=2)+'\n')
print(image)
if __name__=='__main__':main()

View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Build a fresh ext4 userdata image. No connection to a phone or partition writes."""
import argparse
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import uuid
from persistent_state import FEATURES
from tundra import digest
from standalone_volume import checked_volume
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--prepared', required=True, type=Path)
parser.add_argument('--output', required=True, type=Path)
parser.add_argument('--hardware', type=Path, help='Locally supplied pinned vendor/modem images')
parser.add_argument('--size-mib', type=int, default=8192)
args = parser.parse_args()
if not 5120 <= args.size_mib <= 32768:
parser.error('Choose 5120–32768 MiB')
spec = importlib.util.spec_from_file_location('stage', Path(__file__).with_name('stage-sargo-files.py'))
stage = importlib.util.module_from_spec(spec); spec.loader.exec_module(stage)
files = stage.inputs(args.prepared)
hardware = {}
if args.hardware:
lock = json.loads((Path(__file__).resolve().parents[1]/'manifests/sargo-hardware.json').read_text())
hardware = lock['images']
for name, record in hardware.items():
source = args.hardware/record['source']
if source.is_symlink() or source.stat().st_size != record['bytes'] or digest(source) != record['sha256']:
raise ValueError('Hardware image changed: '+name)
payload_bytes = sum(p.stat().st_size for _,_,p,_ in files)
payload_bytes += sum(record['bytes'] for record in hardware.values())
if args.size_mib*1024**2 < payload_bytes+512*1024**2:
raise ValueError('Insufficient space for payload and filesystem metadata')
os.umask(0o077)
out = args.output.resolve(); out.mkdir(parents=True, exist_ok=False)
(out/'.incomplete').touch()
seed = out/'seed'; seed.mkdir()
for kind, name, source, record in files:
target = seed/'tundra'/kind/name; target.parent.mkdir(parents=True, exist_ok=True)
# mke2fs reads the file; a hard link avoids another multi-gigabyte copy.
os.link(source, target)
if hardware:
directory = seed/'tundra/hardware'; directory.mkdir()
for name, record in hardware.items():
os.link(args.hardware/record['source'], directory/name)
system = json.loads((args.prepared/'system.json').read_text())
state = json.loads((args.prepared/'data/state.json').read_text())
identifier = str(uuid.uuid4())
(seed/'tundra-volume-id').write_text(identifier+'\n')
image = out/'userdata.ext4'
with image.open('xb') as f: f.truncate(args.size_mib*1024**2)
subprocess.run(['mke2fs','-q','-t','ext4','-F','-b','4096','-I','256','-O',FEATURES,
'-U',identifier,'-L','TUNDRA_VOLUME','-d',str(seed),str(image)],check=True)
names=['/tundra-volume-id','/tundra','/tundra/images','/tundra/data']
names += ['/tundra/'+kind+'/'+name for kind,name,_,_ in files]
if hardware:
names += ['/tundra/hardware', *['/tundra/hardware/'+name for name in hardware]]
for name in names:
for field in ('uid','gid'):
subprocess.run(['debugfs','-w','-R',f'set_inode_field {name} {field} 0',str(image)],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
result = subprocess.run(['e2fsck','-f','-n',str(image)],capture_output=True,text=True)
(out/'filesystem-check.txt').write_text(result.stdout+result.stderr)
if result.returncode: raise ValueError('Standalone filesystem check failed')
manifest={'schema':1,'device':'sargo','storage':'raw-userdata-ext4','image':image.name,
'uuid':identifier,'bytes':image.stat().st_size,'imageSha256':digest(image),
'systemImageSha256':system['imageSha256'],'stateUuid':state['uuid'],
'filesystemCheckPassed':True,'flashReady':False,'hardwareImages':hardware,
'scope':'Fresh standalone storage; flashing userdata destroys previous user data'}
(out/'volume.json').write_text(json.dumps(manifest,indent=2)+'\n')
checked_volume(out/'volume.json')
(out/'.incomplete').unlink()
print(out/'volume.json')
if __name__ == '__main__': main()

View file

@ -128,7 +128,7 @@ def main():
ignore=shutil.ignore_patterns('__pycache__', 'test', 'tests'))
for name in ('tools', 'tests', 'logs', 'profiles'):
(product/name).mkdir(exist_ok=True)
for name in ('serve-gaia.py', 'project.py', 'run-host.py'):
for name in ('serve-gaia.py', 'project.py', 'run-host.py', 'installed_apps.py'):
shutil.copy2(PROJECT/'tools'/name, product/'tools'/name)
shutil.copy2(PROJECT/'tests/ProbeChild.sys.mjs', product/'tests/ProbeChild.sys.mjs')
shutil.copy2(PROJECT/'release.json', product/'release.json')

View file

@ -0,0 +1,116 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Install a standalone Sargo package using Fastboot only (Python 3, any host OS).
Default: verify files and display the destructive installation plan.
--install --erase-userdata replaces the old OS and ALL files in userdata.
Keep a separate backup. The unlocked bootloader and device calibration remain.
"""
import argparse
import hashlib
import json
from pathlib import Path
import re
import shutil
import struct
import subprocess
import sys
PARTITIONS=('userdata','dtbo_a','vbmeta_a','boot_a')
NAMES={'userdata':'userdata.img','dtbo_a':'dtbo.img','vbmeta_a':'vbmeta.img','boot_a':'boot.img'}
def digest(path):
sha=hashlib.sha256()
with path.open('rb') as source:
while block:=source.read(1024*1024):sha.update(block)
return sha.hexdigest()
def sparse_size(path):
with path.open('rb') as source:header=source.read(28)
if len(header)!=28:raise ValueError('Truncated userdata image')
magic,major,minor,file_header,chunk_header,block_size,blocks,chunks,crc=struct.unpack('<I4H4I',header)
if magic!=0xed26ff3a or major!=1 or file_header!=28 or chunk_header!=12 or block_size!=4096 or not chunks:
raise ValueError('Expected an Android sparse ext4 image')
return block_size*blocks
def package(folder):
folder=Path(folder).resolve()
if (folder/'.incomplete').exists():raise ValueError('Package construction is incomplete')
value=json.loads((folder/'package.json').read_text())
if value.get('schema')!=1 or value.get('device')!='sargo' or value.get('storage')!='raw-userdata-ext4':
raise ValueError('Wrong package model or storage format')
if set(value.get('partitions',{}))!=set(PARTITIONS):raise ValueError('Unexpected partition write plan')
for partition,record in value['partitions'].items():
if record.get('file')!=NAMES[partition]:raise ValueError('Unexpected image filename')
path=folder/record['file']
if path.is_symlink() or path.stat().st_size!=record['bytes'] or digest(path)!=record['sha256']:
raise ValueError('Image checksum or size mismatch: '+partition)
if sparse_size(folder/'userdata.img')!=value['userdataBytes']:raise ValueError('Sparse userdata geometry changed')
return value
def getvar(fastboot,serial,name):
result=subprocess.run([fastboot,'-s',serial,'getvar',name],capture_output=True,text=True,check=True,timeout=15)
for line in (result.stdout+'\n'+result.stderr).splitlines():
match=re.fullmatch(r'(?:\(bootloader\)\s*)?'+re.escape(name)+r':\s*(.+)',line.strip())
if match:return match[1].strip()
raise ValueError('Missing Fastboot variable: '+name)
def preflight(fastboot,serial,value):
info={name:getvar(fastboot,serial,name) for name in ('product','unlocked','current-slot')}
if info['product']!='sargo' or info['unlocked']!='yes' or info['current-slot'] not in ('a','b'):
raise ValueError('Requires an unlocked Pixel 3a (sargo), not Pixel 3a XL')
for partition in PARTITIONS:
size=int(getvar(fastboot,serial,'partition-size:'+partition),16)
needed=value['userdataBytes'] if partition=='userdata' else value['partitions'][partition]['bytes']
if needed>size:raise ValueError('Image exceeds partition: '+partition)
return info
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--package',type=Path,default=Path(__file__).resolve().parent)
parser.add_argument('--serial')
parser.add_argument('--install',action='store_true')
parser.add_argument('--erase-userdata',action='store_true')
parser.add_argument('--experimental',action='store_true')
parser.add_argument('--reboot',action='store_true')
args=parser.parse_args();value=package(args.package)
print('Pixel 3a / Sargo: replace userdata, dtbo_a, vbmeta_a and boot_a; activate slot A.')
print('ALL existing user data and the old OS in userdata will be lost. Bootloader remains unlocked.')
print('No existing Android or Droidian installation is required.')
if not args.install:
print('Review only; no device command sent.');return
if not args.erase_userdata:parser.error('--install requires --erase-userdata')
if not value.get('flashQualified') and not args.experimental:
parser.error('This candidate is not qualified; private tests require --experimental')
fastboot=shutil.which('fastboot')
if not fastboot:raise ValueError('Install Android platform-tools (fastboot) on the host computer')
devices=subprocess.check_output([fastboot,'devices'],text=True,timeout=10).splitlines()
serials=[line.split()[0] for line in devices if len(line.split())==2 and line.split()[1]=='fastboot']
serial=args.serial
if serial is None:
if len(serials)!=1:raise ValueError('Connect exactly one Pixel or specify --serial')
serial=serials[0]
if serial not in serials:raise ValueError('Selected device not in Fastboot')
info=preflight(fastboot,serial,value)
report={'deviceSha256':hashlib.sha256(serial.encode()).hexdigest(),'preflight':info,'completed':[],'passed':False}
path=args.package/'install-report.json'
def save():path.write_text(json.dumps(report,indent=2)+'\n')
save()
subprocess.run([fastboot,'-s',serial,'erase','userdata'],check=True,timeout=300)
report['completed'].append('erase:userdata');save()
for partition in PARTITIONS:
subprocess.run([fastboot,'-s',serial,'flash',partition,str((args.package/NAMES[partition]).resolve())],check=True,timeout=1200)
report['completed'].append(partition);save()
subprocess.run([fastboot,'-s',serial,'set_active','a'],check=True,timeout=30)
report['passed']=True;save()
if args.reboot:subprocess.run([fastboot,'-s',serial,'reboot'],check=True,timeout=30)
print('Images written. Keep the bootloader unlocked. Select Start to launch Vulpes.')
if __name__=='__main__':main()

View file

@ -23,6 +23,8 @@ def load(name, path):
def checked_build(folder):
folder=Path(folder).resolve()
build=json.loads((folder/'build.json').read_text())
if build.get('storageMode') in ('raw-userdata-ext4', 'container-test'):
raise ValueError('Use the standalone installer; the old boot-only plan is incompatible')
if build.get('localDebugger'):raise ValueError('Diagnostic debugger image cannot be flashed')
qualification=json.loads((folder/'qualification.json').read_text())
if (not qualification.get('passed') or not qualification.get('privateInstallReady')

View file

@ -0,0 +1,63 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Package boot, fresh userdata and boot metadata for a standalone installation.
Builds local artifacts only. A candidate is never marked flash-qualified here.
Private development credentials, if present in boot, prohibit redistribution.
"""
import argparse
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
from standalone_volume import checked_volume
from tundra import ROOT,digest
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build',required=True,type=Path)
parser.add_argument('--volume',required=True,type=Path)
parser.add_argument('--dtbo',required=True,type=Path)
parser.add_argument('--output',required=True,type=Path)
args=parser.parse_args();os.umask(0o077)
volume=checked_volume(args.volume);build=json.loads((args.build/'build.json').read_text())
if build.get('storageMode')!='raw-userdata-ext4' or build.get('watchdogSeconds') is not None or build.get('localDebugger'):
raise ValueError('Expected a persistent standalone boot without debugger or timed reboot')
if build['standaloneVolume']!=volume or not volume.get('hardwareImages'):
raise ValueError('Boot must match a volume with bundled vendor/modem images')
boot=args.build/build['image']
if digest(boot)!=build['imageSha256']:raise ValueError('Boot image changed')
spec=importlib.util.spec_from_file_location('sources',ROOT/'tools/test-rescue-boot.py')
sources=importlib.util.module_from_spec(spec);spec.loader.exec_module(sources);sources.verify_sources(args.build,build)
lock=json.loads((ROOT/'manifests/sargo-hardware.json').read_text())
if args.dtbo.stat().st_size!=lock['dtbo']['bytes'] or digest(args.dtbo)!=lock['dtbo']['sha256']:
raise ValueError('Unqualified device-tree overlay')
out=args.output.resolve();out.mkdir(parents=True,exist_ok=False)
(out/'.incomplete').touch()
shutil.copyfile(boot,out/'boot.img');shutil.copyfile(args.dtbo,out/'dtbo.img')
subprocess.run(['avbtool','make_vbmeta_image','--flags','3','--padding_size','4096','--algorithm','NONE','--output',str(out/'vbmeta.img')],check=True)
subprocess.run(['img2simg',str(args.volume.parent/volume['image']),str(out/'userdata.img')],check=True)
# Roundtrip verifies the actual sparse payload, not only its header.
restored=out/'userdata-roundtrip.ext4'
subprocess.run(['simg2img',str(out/'userdata.img'),str(restored)],check=True)
if digest(restored)!=volume['imageSha256']:raise ValueError('Sparse image roundtrip mismatch')
restored.unlink()
files={name:{'file':file,'bytes':(out/file).stat().st_size,'sha256':digest(out/file)}
for name,file in [('boot_a','boot.img'),('userdata','userdata.img'),('dtbo_a','dtbo.img'),('vbmeta_a','vbmeta.img')]}
value={'schema':1,'device':'sargo','storage':'raw-userdata-ext4','partitions':files,
'userdataBytes':volume['bytes'],'userdataRawSha256':volume['imageSha256'],
'volumeUuid':volume['uuid'],'systemImageSha256':volume['systemImageSha256'],
'flashQualified':False,'containsPrivateMaintenanceKey':bool(build.get('containsPrivateTrialHostKey')),
'redistributionReady':False,'previousOsRequired':False,
'requirements':['Unlocked Pixel 3a (sargo)','Original device calibration partitions retained'],
'scope':'Experimental standalone installation candidate; userdata is replaced in full'}
(out/'package.json').write_text(json.dumps(value,indent=2)+'\n')
shutil.copyfile(ROOT/'tools/flash-standalone-sargo.py',out/'flash-standalone-sargo.py')
(out/'SHA256SUMS').write_text(''.join(digest(p)+' '+p.name+'\n' for p in sorted(out.iterdir()) if p.is_file() and p.name not in ('.incomplete','SHA256SUMS')))
(out/'.incomplete').unlink();print(out/'package.json')
if __name__=='__main__':main()

View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Restore a private full-storage backup after a standalone Sargo test.
Default: verify the archive and partition backups only. --restore overwrites
userdata and the three slot-A boot partitions. Backups are device-specific.
"""
import argparse
import gzip
import hashlib
import json
from pathlib import Path
import shutil
import subprocess
def digest(path):
sha=hashlib.sha256()
with path.open('rb') as source:
while block:=source.read(4*1024**2):sha.update(block)
return sha.hexdigest()
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--backup',required=True,type=Path)
parser.add_argument('--restore',action='store_true')
args=parser.parse_args();folder=args.backup.resolve()
manifest=json.loads((folder/'recovery.json').read_text())
if manifest.get('device')!='sargo' or manifest.get('activeSlot')!='a':raise ValueError('Wrong recovery target')
data=json.loads((folder/'userdata.json').read_text())
if not data.get('checked') or data.get('archive')!='userdata-original.raw.gz':raise ValueError('Unverified userdata backup')
if not data.get('sourceWasUnmounted') or manifest.get('userdataSha256')!=data['sha256'] or manifest.get('userdataBytes')!=data['bytes']:
raise ValueError('Storage backup is not bound to this recovery kit')
for name,record in manifest['files'].items():
if name not in ('boot_a.img','dtbo_a.img','vbmeta_a.img'):raise ValueError('Unexpected recovery partition')
path=folder/name
if path.is_symlink() or path.stat().st_size!=record['bytes'] or digest(path)!=record['sha256']:raise ValueError('Partition backup changed: '+name)
if set(manifest['files'])!={'boot_a.img','dtbo_a.img','vbmeta_a.img'}:raise ValueError('Incomplete boot backup set')
archive=folder/data['archive'];sha=hashlib.sha256();count=0
with gzip.open(archive,'rb') as source:
while block:=source.read(4*1024**2):
count+=len(block)
if count>data['bytes']:raise ValueError('Oversized backup')
sha.update(block)
if count!=data['bytes'] or sha.hexdigest()!=data['sha256']:raise ValueError('Storage backup changed')
print('Private boot and full userdata backups verified.')
if not args.restore:
print('No phone action. --restore replaces its current userdata.');return
fastboot=shutil.which('fastboot')
if not fastboot:raise ValueError('Fastboot is required')
serials=[line.split()[0] for line in subprocess.check_output([fastboot,'devices'],text=True).splitlines() if len(line.split())==2]
matches=[serial for serial in serials if hashlib.sha256(serial.encode()).hexdigest()==manifest['serialSha256']]
if len(matches)!=1:raise ValueError('Original phone not present in Fastboot')
serial=matches[0]
# Use the standalone installer's strict Fastboot variable parser.
import importlib.util
spec=importlib.util.spec_from_file_location('flash',Path(__file__).with_name('flash-standalone-sargo.py'))
flash=importlib.util.module_from_spec(spec);spec.loader.exec_module(flash)
if flash.getvar(fastboot,serial,'product')!='sargo' or flash.getvar(fastboot,serial,'unlocked')!='yes':raise ValueError('Wrong or locked phone')
if int(flash.getvar(fastboot,serial,'partition-size:userdata'),16)!=data['bytes']:raise ValueError('Storage geometry changed')
raw=folder/'userdata-restore.raw'
if raw.exists():
if raw.stat().st_size!=data['bytes'] or digest(raw)!=data['sha256']:raise ValueError('Existing restoration file changed')
else:
if shutil.disk_usage(folder).free<data['bytes']+1024**3:raise ValueError('Not enough host space to decompress recovery image')
partial=folder/'userdata-restore.partial'
with gzip.open(archive,'rb') as source,partial.open('xb') as target:
while block:=source.read(4*1024**2):
if not block.strip(b'\0'):target.seek(len(block),1)
else:target.write(block)
target.truncate(data['bytes'])
if digest(partial)!=data['sha256']:raise ValueError('Decompressed backup changed')
partial.rename(raw)
subprocess.run([fastboot,'-s',serial,'erase','userdata'],check=True,timeout=300)
subprocess.run([fastboot,'-s',serial,'-S','256M','flash','userdata',str(raw)],check=True,timeout=7200)
for name in ('dtbo_a.img','vbmeta_a.img','boot_a.img'):
subprocess.run([fastboot,'-s',serial,'flash',name.removesuffix('.img'),str(folder/name)],check=True,timeout=120)
subprocess.run([fastboot,'-s',serial,'set_active','a'],check=True,timeout=30)
print('Original userdata and boot restored. Select Start; bootloader remains unlocked.')
if __name__=='__main__':main()

View file

@ -0,0 +1,25 @@
# SPDX-License-Identifier: MPL-2.0
"""Decode the read-only TUNRAW1 stream; reject truncation and overlapping blocks."""
import struct
def exact(stream, length):
data=bytearray()
while len(data)<length:
block=stream.read(length-len(data))
if not block:raise ValueError('Truncated backup stream')
data.extend(block)
return bytes(data)
def receive(stream, target, expected_size):
if exact(stream,8)!=b'TUNRAW1\0':raise ValueError('Unexpected backup protocol')
end=0
while True:
offset,length=struct.unpack('<QQ',exact(stream,16))
if not length:
if offset!=expected_size or stream.read(1):raise ValueError('Backup size or trailer mismatch')
target.truncate(expected_size)
return
if offset<end or length>65536 or offset+length>expected_size:raise ValueError('Invalid backup extent')
target.seek(offset);target.write(exact(stream,length));end=offset+length

View file

@ -0,0 +1,35 @@
# SPDX-License-Identifier: MPL-2.0
"""Validate the fresh userdata filesystem used by standalone Sargo images."""
import json
from pathlib import Path
import re
import uuid
from tundra import digest
def checked_volume(path, verify_image=True):
path = Path(path)
value = json.loads(path.read_text())
if (value.get('schema') != 1 or value.get('device') != 'sargo'
or value.get('storage') != 'raw-userdata-ext4'
or value.get('image') != 'userdata.ext4'
or value.get('filesystemCheckPassed') is not True):
raise ValueError('Expected a checked standalone Sargo volume')
identifier = value.get('uuid')
if not isinstance(identifier, str) or str(uuid.UUID(identifier)) != identifier:
raise ValueError('Invalid volume UUID')
for key in ('imageSha256', 'systemImageSha256'):
if not re.fullmatch(r'[a-f0-9]{64}', value.get(key, '')):
raise ValueError('Invalid '+key)
if type(value.get('bytes')) is not int or not 5*1024**3 <= value['bytes'] <= 32*1024**3:
raise ValueError('Invalid standalone filesystem size')
hardware = value.get('hardwareImages', {})
if hardware:
lock = json.loads((Path(__file__).resolve().parents[1]/'manifests/sargo-hardware.json').read_text())
if hardware != lock['images']:
raise ValueError('Unqualified standalone hardware images')
if verify_image:
image = path.parent/value['image']
if image.is_symlink() or image.stat().st_size != value['bytes'] or digest(image) != value['imageSha256']:
raise ValueError('Standalone volume changed')
return value