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:
parent
d04824ec89
commit
0041cd9660
101 changed files with 3883 additions and 335 deletions
|
|
@ -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())
|
||||
|
||||
|
||||
|
|
|
|||
47
tundra/tests/test_runtime_packages.py
Normal file
47
tundra/tests/test_runtime_packages.py
Normal 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()
|
||||
37
tundra/tests/test_sparse_backup.py
Normal file
37
tundra/tests/test_sparse_backup.py
Normal 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()
|
||||
91
tundra/tests/test_standalone_boot.py
Normal file
91
tundra/tests/test_standalone_boot.py
Normal 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()
|
||||
80
tundra/tests/test_standalone_flash.py
Normal file
80
tundra/tests/test_standalone_flash.py
Normal 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()
|
||||
69
tundra/tests/test_standalone_restore.py
Normal file
69
tundra/tests/test_standalone_restore.py
Normal 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()
|
||||
Loading…
Reference in a new issue