Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

6
tundra/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/sources/
/downloads/
/out/
/logs/
__pycache__/
*.pyc

373
tundra/LICENSE Normal file
View file

@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.

108
tundra/boot/init.c Normal file
View file

@ -0,0 +1,108 @@
/* SPDX-License-Identifier: MPL-2.0
* Stage-zero diagnostic init. No disks, network or Android framework.
* Keep this executable independent of libc to use it on both test targets.
*/
#if defined(__x86_64__)
#define NR_write 1
#define NR_read 0
#define NR_close 3
#define NR_openat 257
#define NR_mount 165
#define NR_getpid 39
#define NR_exit 60
#define NR_reboot 169
#define NR_nanosleep 35
static long call(long n, long a, long b, long c, long d, long e) {
register long r10 __asm__("r10") = d;
register long r8 __asm__("r8") = e;
long result;
__asm__ volatile("syscall" : "=a"(result)
: "a"(n), "D"(a), "S"(b), "d"(c), "r"(r10), "r"(r8)
: "rcx", "r11", "memory");
return result;
}
#elif defined(__aarch64__)
#define NR_write 64
#define NR_read 63
#define NR_close 57
#define NR_openat 56
#define NR_mount 40
#define NR_getpid 172
#define NR_exit 93
#define NR_reboot 142
#define NR_nanosleep 101
static long call(long n, long a, long b, long c, long d, long e) {
register long x8 __asm__("x8") = n;
register long x0 __asm__("x0") = a;
register long x1 __asm__("x1") = b;
register long x2 __asm__("x2") = c;
register long x3 __asm__("x3") = d;
register long x4 __asm__("x4") = e;
__asm__ volatile("svc 0" : "+r"(x0)
: "r"(x8), "r"(x1), "r"(x2), "r"(x3), "r"(x4)
: "memory");
return x0;
}
#else
#error Unsupported architecture
#endif
static void say(const char *s) {
long n = 0;
while (s[n]) ++n;
while (n > 0) {
long written = call(NR_write, 1, (long)s, n, 0, 0);
if (written <= 0) break;
s += written;
n -= written;
}
}
static int contains(const char *s, const char *needle) {
for (; *s; ++s) {
long i = 0;
while (needle[i] && s[i] == needle[i]) ++i;
if (!needle[i]) return 1;
}
return 0;
}
static void file(const char *path, char *buffer, long capacity) {
long fd = call(NR_openat, -100, (long)path, 0, 0, 0);
buffer[0] = 0;
if (fd < 0) return;
long n = call(NR_read, fd, (long)buffer, capacity - 1, 0, 0);
if (n > 0) buffer[n] = 0;
call(NR_close, fd, 0, 0, 0, 0);
}
__attribute__((noreturn)) void _start(void) {
if (call(NR_getpid, 0, 0, 0, 0, 0) != 1) {
say("Tundra init must only run as PID 1 in the diagnostic ramdisk.\n");
call(NR_exit, 2, 0, 0, 0, 0);
for (;;) {}
}
say("TUNDRA_STAGE0_START\n");
long proc = call(NR_mount, (long)"proc", (long)"/proc", (long)"proc", 14, 0);
long sys = call(NR_mount, (long)"sysfs", (long)"/sys", (long)"sysfs", 14, 0);
char buffer[4096];
file("/proc/version", buffer, sizeof(buffer));
say(buffer);
file("/proc/cmdline", buffer, sizeof(buffer));
int selftest = contains(buffer, "tundra.selftest=1");
if (proc == 0 && sys == 0 && buffer[0]) {
say("TUNDRA_STAGE0_OK pid1 proc sysfs cmdline\n");
} else {
say("TUNDRA_STAGE0_FAILED pseudo-filesystems\n");
}
say("Diagnostic only: no Gecko, Gaia, HAL, USB service or persistent mounts.\n");
if (selftest) {
say("TUNDRA_STAGE0_POWEROFF\n");
call(NR_reboot, 0xfee1dead, 672274793, 0x4321fedc, 0, 0);
}
for (;;) {
long delay[2] = {15, 0};
call(NR_nanosleep, (long)delay, 0, 0, 0, 0);
say("TUNDRA_STAGE0_ALIVE\n");
}
}

82
tundra/boot/native-root.sh Executable file
View file

@ -0,0 +1,82 @@
#!/bin/sh
# SPDX-License-Identifier: MPL-2.0
# Called by rescue PID 1 only after the read-only storage check succeeds.
set -eu
# BUILD_TIME_FLOOR
# RUNTIME_CONFIG_OVERLAY
# A kernel fallback date must not precede the build; RTC is not modified.
if [ "$(date +%s)" -lt "${BUILD_EPOCH:-0}" ]; then date -s "@$BUILD_EPOCH"; fi
trap 'echo TUNDRA_NATIVE_SETUP_FAILED; while :; do /bin/busybox sleep 5; done' EXIT
LOWER=/installed/home/droidian/vulpes-tundra-tests/runtime-reference-20260925/root
if [ -n "${SYSTEM_IMAGE:-}" ]; then
test "$(sha256sum "$SYSTEM_IMAGE" | cut -d' ' -f1)" = "$SYSTEM_IMAGE_SHA"
mkdir -p /run/tundra-system
mount -t ext4 -o loop,ro,noload "$SYSTEM_IMAGE" /run/tundra-system
LOWER=/run/tundra-system
fi
test -f "$LOWER/etc/systemd/system/tundra.target"
UPPER=/run/native-upper
WORK=/run/native-work
if [ -n "${STATE_IMAGE:-}" ]; then
test -f "$STATE_IMAGE" && test ! -L "$STATE_IMAGE"
test "$(stat -c %s "$STATE_IMAGE")" = "$STATE_BYTES"
blkid "$STATE_IMAGE" | grep -F "UUID=\"$STATE_UUID\"" >/dev/null
repaired=0
e2fsck -p "$STATE_IMAGE" || repaired=$?
test "$repaired" -le 1
mkdir -p /run/tundra-state
mount -t ext4 -o loop,rw,nosuid,nodev "$STATE_IMAGE" /run/tundra-state
# A data layer belongs to one immutable system image. Refuse accidental reuse.
test "$(cat /run/tundra-state/system.sha256)" = "$SYSTEM_IMAGE_SHA"
UPPER=/run/tundra-state/upper
WORK=/run/tundra-state/work
fi
mkdir -p "$UPPER" "$WORK" /newroot
chmod 755 "$UPPER"
mount -t overlay overlay -o "lowerdir=$LOWER,upperdir=$UPPER,workdir=$WORK" /newroot
if [ -z "${SYSTEM_IMAGE:-}" ] || [ "${APPLY_NATIVE_CONFIG:-0}" = 1 ]; then
# Only the selected upper layer is edited; the system image stays read-only.
if [ -n "${STATE_IMAGE:-}" ]; then
mkdir -p /run/tundra-identity
for identity in machine-id passwd group shadow gshadow; do
if [ -s "/newroot/etc/$identity" ]; then
cp -p "/newroot/etc/$identity" "/run/tundra-identity/$identity"
fi
done
fi
rm -f /newroot/etc/tundra/hardware-enabled /newroot/etc/systemd/system/tundra.target.wants/ofono.service /newroot/etc/systemd/system/tundra.target.wants/tundra-audio.service /newroot/etc/tundra/network-enabled /newroot/etc/systemd/system/tundra.target.wants/NetworkManager.service /newroot/etc/systemd/system/tundra.target.wants/tundra-wifi.service
tar -xf /native-config.tar -C /newroot
if [ -d /run/tundra-identity ]; then
for identity in /run/tundra-identity/*; do
[ -f "$identity" ] && cp -p "$identity" /newroot/etc/
done
chmod 644 /newroot/etc/machine-id
fi
fi
if [ -f /shared-ui.tar ]; then tar -xf /shared-ui.tar -C /newroot; fi
# A previous successful session must never count as proof of this boot.
for log in test.json home.png session.pid gecko.log server.log compositor.log network-gecko.json wifi-ui.json; do
rm -f "/newroot/opt/vulpes/logs/$log"
done
mkdir -p /newroot/reference /newroot/run /newroot/root/.ssh
if [ -z "${SYSTEM_IMAGE:-}" ]; then
: > /newroot/reference/android-rootfs.img
mount --bind /installed/var/lib/lxc/android/android-rootfs.img /newroot/reference/android-rootfs.img
else
test -s /newroot/reference/android-rootfs.img
fi
# Installed lower layers stay reachable for the overlay/loop driver until reboot.
mkdir -p /newroot/reference/installed
mount --move /installed /newroot/reference/installed
# Keep a minimal, already tested rescue environment in RAM. Its SSH account
# does not depend on PAM/NSS/shell configuration in the experimental root.
mkdir -p /run/rescue-root
(cd / && tar -c bin sbin lib etc root) | (cd /run/rescue-root && tar -x)
mkdir -p /run/rescue-root/dev /run/rescue-root/proc /run/rescue-root/run
mount --rbind /dev /run/rescue-root/dev
mount --bind /proc /run/rescue-root/proc
kill "$(cat /run/dropbear.pid)"
chroot /run/rescue-root /sbin/dropbear -E -s -j -k -p 10.15.19.82:2222 -r /etc/tundra-hostkey -P /run/dropbear.pid
for fs in dev proc sys run; do mount --move "/$fs" "/newroot/$fs"; done
# A static watchdog survives switch_root without needing the old /bin or /proc.
exec switch_root /newroot /sbin/init --unit=tundra.target

55
tundra/boot/rescue-init.sh Executable file
View file

@ -0,0 +1,55 @@
#!/bin/busybox sh
# SPDX-License-Identifier: MPL-2.0
# Temporary RAM-only boot. Installed data is never mounted writable.
export PATH=/bin:/sbin
/bin/busybox --install -s /bin
mount -t proc proc /proc
ln -s /proc/mounts /etc/mtab
mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev
mkdir -p /dev/pts /run /sys/kernel/config
mount -t devpts devpts /dev/pts
mount -t tmpfs tmpfs /run
exec >>/run/boot.log 2>&1
# This runs before USB/storage setup, so a later failure still returns to Droidian.
(sleep 480; echo 'Tundra trial deadline reached'; reboot -f) &
echo TUNDRA_RESCUE_STARTED
case "$(cat /proc/cmdline)" in *tundra.rescue=1*) ;; *) reboot -f;; esac
mdev -s
chmod 666 /dev/null /dev/zero /dev/random /dev/urandom /dev/ptmx
mount -t configfs configfs /sys/kernel/config
G=/sys/kernel/config/usb_gadget/tundra
mkdir -p "$G"
echo 0x1d6b > "$G/idVendor"
echo 0x0104 > "$G/idProduct"
mkdir -p "$G/strings/0x409" "$G/configs/c.1/strings/0x409" "$G/functions/rndis.usb0"
echo TUNDRA-TRIAL > "$G/strings/0x409/serialnumber"
echo 'Tundra development' > "$G/strings/0x409/manufacturer"
echo 'Temporary runtime trial' > "$G/strings/0x409/product"
echo 250 > "$G/configs/c.1/MaxPower"
ln -s "$G/functions/rndis.usb0" "$G/configs/c.1/rndis"
for i in $(seq 1 100); do
UDC=$(ls /sys/class/udc | head -n1)
[ -n "$UDC" ] && break
sleep 0.1
done
echo "$UDC" > "$G/UDC"
ip link set lo up
for iface in rndis0 usb0; do
if [ -d "/sys/class/net/$iface" ]; then
ip addr add 10.15.19.82/24 dev "$iface"
ip link set "$iface" up
break
fi
done
/sbin/dropbear -E -s -j -k -p 10.15.19.82:2222 -r /etc/tundra-hostkey -P /run/dropbear.pid
echo TUNDRA_USB_SSH_READY
# No LVM activation, fsck, journal replay, resizing, or writes to a block device.
if [ -b /dev/mmcblk0p72 ] && [ "$(dd if=/dev/mmcblk0p72 bs=1048576 count=1 2>/dev/null | sha256sum | cut -d' ' -f1)" = "$EXPECTED_STORAGE_HASH" ]; then
mkdir -p /dev/mapper /installed
/sbin/dmsetup create tundra-installed-ro --readonly --table '0 104448000 linear /dev/mmcblk0p72 329728'
/sbin/dmsetup mknodes
mount -t ext4 -o ro,noload /dev/mapper/tundra-installed-ro /installed
fi
echo TUNDRA_RESCUE_READY
while :; do sleep 5; done

132
tundra/boot/usb-init.c Normal file
View file

@ -0,0 +1,132 @@
/* SPDX-License-Identifier: MPL-2.0
* Read-only, development-only USB diagnostics over Linux FunctionFS.
* No shell, block-device access, reboot command or arbitrary file service.
*/
#define _start stage0_start
#include "init.c"
#undef _start
#if defined(__x86_64__)
#define NR_mkdirat 258
#define NR_symlinkat 266
#define NR_getdents64 217
#else
#define NR_mkdirat 34
#define NR_symlinkat 36
#define NR_getdents64 61
#endif
#define G "/sys/kernel/config/usb_gadget/tundra"
typedef unsigned int u32;
static long length(const char *s) { long n=0; while(s[n]) ++n; return n; }
static void pause_ms(long ms) {
long t[2]={ms/1000,(ms%1000)*1000000}; call(NR_nanosleep,(long)t,0,0,0,0);
}
static void fail(const char *s) {
say("TUNDRA_USB_FAILED ");say(s);say("\n");
for (;;) pause_ms(10000);
}
static void directory(const char *p) {
long r=call(NR_mkdirat,-100,(long)p,0755,0,0);
if(r<0 && r!=-17) fail(p);
}
static void put(const char *p,const char *value) {
long fd=call(NR_openat,-100,(long)p,1,0,0);
if(fd<0) fail(p);
long n=length(value);
if(call(NR_write,fd,(long)value,n,0,0)!=n) fail(p);
call(NR_close,fd,0,0,0,0);
}
static void send(long fd,const char *value) {
long n=length(value);
while(n) {
long r=call(NR_write,fd,(long)value,n,0,0);
if(r<=0) return;
n-=r;value+=r;
}
}
static int equal(const char *a,const char *b) {
while(*a && *b && *a==*b) {++a;++b;}return *a==*b;
}
static const unsigned char descriptors[]={
/* v2 header: magic, length, FS+HS+SS flags and descriptor counts */
3,0,0,0, 105,0,0,0, 7,0,0,0, 3,0,0,0, 3,0,0,0, 5,0,0,0,
9,4,0,0,2,255,0,0,1, 7,5,0x81,2,64,0,0, 7,5,0x02,2,64,0,0,
9,4,0,0,2,255,0,0,1, 7,5,0x81,2,0,2,0, 7,5,0x02,2,0,2,0,
9,4,0,0,2,255,0,0,1,
7,5,0x81,2,0,4,0, 6,48,0,0,0,0,
7,5,0x02,2,0,4,0, 6,48,0,0,0,0
};
static const struct __attribute__((packed)) {
u32 magic,length,count,languages;
unsigned short language;
char name[18];
} strings={2,36,1,1,0x0409,"Tundra diagnostic"};
static void bind_udc(void) {
char data[4096];
for(int retry=0;retry<300;retry++) {
long fd=call(NR_openat,-100,(long)"/sys/class/udc",0,0,0);
if(fd>=0) {
long n=call(NR_getdents64,fd,(long)data,sizeof(data),0,0);
call(NR_close,fd,0,0,0,0);
for(long i=0;i+19<n;) {
unsigned short size=*(unsigned short *)(data+i+16);
if(size<20 || i+size>n) break;
char *name=data+i+19;
if(name[0]!='.') {put(G "/UDC",name);say("TUNDRA_USB_BOUND ");say(name);say("\n");return;}
i+=size;
}
}
pause_ms(100);
}
fail("no USB device controller");
}
__attribute__((noreturn)) void _start(void) {
if(call(NR_getpid,0,0,0,0,0)!=1) {
say("USB diagnostic must be PID 1 in its diagnostic environment.\n");
call(NR_exit,2,0,0,0,0);for(;;){}
}
call(NR_mount,(long)"proc",(long)"/proc",(long)"proc",14,0);
char buffer[4096];file("/proc/cmdline",buffer,sizeof(buffer));
if(!contains(buffer,"tundra.usb=1")) fail("explicit kernel flag required");
call(NR_mount,(long)"sysfs",(long)"/sys",(long)"sysfs",14,0);
/* Sargo's stock kernel has no devtmpfs. FunctionFS creates its own nodes. */
if(call(NR_mount,(long)"tmpfs",(long)"/dev",(long)"tmpfs",2,0)<0) fail("dev tmpfs");
directory("/sys/kernel/config");
if(call(NR_mount,(long)"configfs",(long)"/sys/kernel/config",(long)"configfs",14,0)<0) fail("configfs");
directory(G);
/* Linux development gadget IDs, not an assigned Vulpes product identity. */
put(G "/idVendor","0x1d6b");put(G "/idProduct","0x0104");
put(G "/bcdUSB","0x0300");
directory(G "/strings/0x409");
put(G "/strings/0x409/manufacturer","Tundra development");
put(G "/strings/0x409/product","Read-only boot diagnostic");
put(G "/strings/0x409/serialnumber","TUNDRA-DIAG");
directory(G "/configs/c.1");put(G "/configs/c.1/MaxPower","250");
directory(G "/functions/ffs.tundra");directory("/dev/ffs-tundra");
if(call(NR_mount,(long)"tundra",(long)"/dev/ffs-tundra",(long)"functionfs",0,0)<0) fail("FunctionFS mount");
long ep0=call(NR_openat,-100,(long)"/dev/ffs-tundra/ep0",2|2048,0,0);
if(ep0<0 || call(NR_write,ep0,(long)descriptors,sizeof(descriptors),0,0)!=sizeof(descriptors)) fail("descriptors");
if(call(NR_write,ep0,(long)&strings,sizeof(strings),0,0)!=sizeof(strings)) fail("strings");
if(call(NR_symlinkat,(long)G "/functions/ffs.tundra",-100,(long)G "/configs/c.1/diagnostic",0,0)<0) fail("function link");
bind_udc();
long input=call(NR_openat,-100,(long)"/dev/ffs-tundra/ep2",2048,0,0);
long output=call(NR_openat,-100,(long)"/dev/ffs-tundra/ep1",1,0,0);
if(input<0 || output<0) fail("endpoints");
say("TUNDRA_USB_READY protocol=1 commands=status,version,mounts\n");
for(;;) {
/* Drain bind/enable events. Unsupported control requests remain stalled. */
char events[96];call(NR_read,ep0,(long)events,sizeof(events),0,0);
long n=call(NR_read,input,(long)buffer,63,0,0);
if(n>0) {
buffer[n]=0;
const char *path=0;
if(equal(buffer,"status\n")) send(output,"TUNDRA_USB_STATUS protocol=1 readOnly=true\nEND\n");
else {
if(equal(buffer,"version\n")) path="/proc/version";
if(equal(buffer,"mounts\n")) path="/proc/mounts";
if(path) {file(path,buffer,sizeof(buffer));send(output,buffer);send(output,"\nEND\n");}
else send(output,"ERROR unsupported command\nEND\n");
}
}
pause_ms(20);
}
}

14
tundra/boot/watchdog.c Normal file
View file

@ -0,0 +1,14 @@
/* SPDX-License-Identifier: MPL-2.0
* Temporary boot deadline. No files are needed after exec, including across
* switch_root. Only started as root by the explicit development initramfs.
*/
#define _start unused_stage0_start
#include "init.c"
#undef _start
__attribute__((noreturn)) void _start(void) {
long remaining[2] = {600, 0};
while (call(NR_nanosleep, (long)remaining, (long)remaining, 0, 0, 0) == -4) {}
say("TUNDRA_TRIAL_DEADLINE_REBOOT\n");
call(NR_reboot, 0xfee1dead, 672274793, 0x01234567, 0, 0);
for (;;) {long t[2]={1,0};call(NR_nanosleep,(long)t,0,0,0,0);}
}

View file

@ -0,0 +1,50 @@
{
"schema": 1,
"device": "sargo",
"name": "Google Pixel 3a",
"architecture": "aarch64",
"soc": "SDM670",
"sharedBoardName": "bonito / sdm710",
"installedOSReportedByOwner": "Droidian 102 (verified by SSH)",
"bootloaderUnlockedReportedByOwner": true,
"installedFirmwareVerified": true,
"flashReady": false,
"diagnosticBoot": {
"reference": "ubports-sargo: BoardConfig-common.mk, Android 9; not yet verified against this device",
"headerVersion": 1,
"pageSize": 4096,
"base": "0x00000000",
"kernelOffset": "0x00008000",
"ramdiskOffset": "0x02000000",
"tagsOffset": "0x01e00000",
"partitionSizeBytes": 67108864,
"cmdline": "console=ttyMSM0,115200n8 androidboot.console=ttyMSM0 printk.devkmsg=on rdinit=/init"
},
"upstreamBases": {
"ubuntuTouch": "Halium 9; documented vendor base PQ3B.190801.002; actual installed base still unknown",
"capyloon": "Android 10; vendor QQ3A.200805.001; Gecko 123 reference"
},
"installedFirmware": "google/sargo/sargo:9/PD2A.190115.029/5320088:user/release-keys",
"installedBoot": {
"kernel": "4.9-124-google-sargo",
"headerVersion": 0,
"pageSize": 4096,
"base": "0x00000000",
"kernelOffset": "0x00008000",
"ramdiskOffset": "0x01000000",
"tagsOffset": "0x00000100",
"partitionSizeBytes": 67108864,
"reference": "docs/SARGO-INVENTORY-2026-09-24.md",
"rawPartitionBackupVerified": true,
"rawHeaderOSVersion": "9.0.0",
"rawHeaderOSPatchLevel": "2022-01"
},
"otherSlot": {
"slot": "b",
"headerVersion": 2,
"headerOSVersion": "15.0.0",
"headerOSPatchLevel": "2026-02",
"matchesRunningKernel": false,
"qualifiedFallback": false
}
}

View file

@ -0,0 +1,80 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Read the six inventoried Sargo boot partitions; never open a device for writing."""
import datetime
import fcntl
import hashlib
import json
import os
from pathlib import Path
import pwd
import stat
import struct
import subprocess
import tempfile
PARTITIONS = {
'boot_a': 67108864, 'boot_b': 67108864,
'dtbo_a': 8417280, 'dtbo_b': 8417280,
'vbmeta_a': 65536, 'vbmeta_b': 65536,
}
def copy_exact(source, destination, size):
digest = hashlib.sha256()
remaining = size
while remaining:
chunk = source.read(min(1024 * 1024, remaining))
if not chunk:
raise RuntimeError('Partition read ended before its declared size')
destination.write(chunk)
digest.update(chunk)
remaining -= len(chunk)
return digest.hexdigest()
def main():
if os.geteuid() != 0:
raise RuntimeError('Partition reads require sudo in your own terminal')
account = pwd.getpwnam('droidian')
getprop = next((p for p in ('/usr/bin/getprop', '/bin/getprop') if Path(p).exists()), None)
if not getprop:
raise RuntimeError('Cannot establish Android device identity')
device = subprocess.check_output([getprop, 'ro.product.device'], text=True, timeout=10).strip()
compatible = Path('/proc/device-tree/compatible').read_bytes().split(b'\0')
if device != 'sargo' or b'google,b4s4-sdm670' not in compatible:
raise RuntimeError('This recipe only supports the inventoried Sargo adaptation')
os.umask(0o077)
folder = Path(tempfile.mkdtemp(prefix='vulpes-boot-backup-', dir='/var/tmp'))
report = {'recordedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'device': device, 'rawPartitions': True, 'restorationTested': False,
'scope': 'Boot, DTBO and vbmeta only; excludes userdata, vendor and system',
'files': {}}
for name, expected_size in PARTITIONS.items():
path = (Path('/dev/disk/by-partlabel') / name).resolve(strict=True)
fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
with os.fdopen(fd, 'rb') as source:
if not stat.S_ISBLK(os.fstat(source.fileno()).st_mode):
raise RuntimeError('Not a block device: ' + str(path))
size = struct.unpack('Q', fcntl.ioctl(source.fileno(), 0x80081272, bytes(8)))[0]
if size != expected_size:
raise RuntimeError('Partition layout differs from the inventory: ' + name)
target = folder / (name + '.img')
with target.open('xb') as output:
digest = copy_exact(source, output, size)
output.flush()
os.fsync(output.fileno())
with target.open('rb') as saved:
if hashlib.file_digest(saved, 'sha256').hexdigest() != digest:
raise RuntimeError('Backup verification failed: ' + name)
report['files'][target.name] = {'source': str(path), 'bytes': size, 'sha256': digest}
(folder / 'manifest.json').write_text(json.dumps(report, indent=2) + '\n')
# Keep incomplete backups root-only; transfer ownership only after all six pass.
for child in folder.iterdir():
os.chown(child, account.pw_uid, account.pw_gid)
os.chown(folder, account.pw_uid, account.pw_gid)
print('Backup verified:', folder)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,102 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Check the ARM64 reference in a private mount namespace, without starting services."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
from urllib.parse import quote
def run(*args, check=True):
result = subprocess.run(args, check=False, capture_output=True, text=True, timeout=45)
if check and result.returncode:
raise RuntimeError(f'{args}: {result.stderr.strip()}')
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--root', type=Path, required=True)
parser.add_argument('--product', type=Path, required=True)
parser.add_argument('--inside-namespace', action='store_true', help=argparse.SUPPRESS)
args = parser.parse_args()
if os.geteuid() != 0:
parser.error('chroot and the private mount namespace require administrator privileges')
root, product = args.root.resolve(), args.product.resolve()
base = Path('/home/droidian/vulpes-tundra-tests')
for path in (root, product):
if not path.is_relative_to(base) or path == base:
parser.error('Expected isolated device trial directories')
if not (root.parent/(root.name+'-stage.json')).is_file():
parser.error('Staging report missing')
if not args.inside_namespace:
result = subprocess.run(['unshare', '--mount', '--net', '--fork', sys.executable,
str(Path(__file__).resolve()), '--inside-namespace', '--root', str(root),
'--product', str(product)], check=False)
raise SystemExit(result.returncode)
if os.readlink('/proc/self/ns/mnt') == os.readlink('/proc/1/ns/mnt'):
parser.error('Refusing to mount in the system namespace')
run('mount', '--make-rprivate', '/')
run('mount', '--bind', str(product), str(root/'opt/vulpes'))
run('mount', '-o', 'remount,bind,ro', str(root/'opt/vulpes'))
run('mount', '-t', 'proc', 'proc', str(root/'proc'))
for name in ('null', 'urandom', 'random'):
(root/'dev'/name).touch(exist_ok=True)
run('mount', '--bind', '/dev/'+name, str(root/'dev'/name))
for name in ('run', 'tmp'):
run('mount', '-t', 'tmpfs', '-o', 'mode=1777,size=128m', 'tmpfs', str(root/name))
os.chown(root/'home/vulpes', 1000, 1000)
profile = root/'home/vulpes/render-profile'
profile.mkdir(exist_ok=True)
os.chown(profile, 1000, 1000)
(root/'home/vulpes/render.png').unlink(missing_ok=True)
run('chroot', str(root), '/sbin/ldconfig')
compiler = '/usr/lib/aarch64-linux-gnu/glib-2.0/glib-compile-schemas'
run('chroot', str(root), compiler, '/usr/share/glib-2.0/schemas')
engine = json.loads((product/'engine-lock.json').read_text())['version']
binary = f'/opt/vulpes/engines/{engine}/firefox/firefox'
commands = {
'python': ['/usr/bin/python3', '-c', 'import platform,ssl,json; print(json.dumps({"machine":platform.machine(),"python":platform.python_version(),"ssl":ssl.OPENSSL_VERSION}))'],
'compositor': ['/usr/bin/phoc', '--version'],
'gecko': [binary, '--headless', '--version'],
'geckoRendering': [binary, '--headless', '--no-remote',
'--profile', '/home/vulpes/render-profile', '--window-size', '360,740',
'--screenshot', '/home/vulpes/render.png',
'data:text/html,'+quote('<!doctype html><meta charset="utf-8"><title>Tundra runtime check</title><body style="background:#123456;color:white">Gecko runtime qualification</body>')],
'geckoLibraries': ['/usr/bin/ldd', binary],
'compositorLibraries': ['/usr/bin/ldd', '/usr/bin/phoc'],
}
results = {}
for name, command in commands.items():
result = run('chroot', '--userspec=1000:1000', str(root), '/usr/bin/env', '-i',
'HOME=/home/vulpes', 'PATH=/usr/bin:/bin:/usr/sbin:/sbin',
'LANG=C', *command, check=False)
results[name] = {'command': command, 'returncode': result.returncode,
'stdout': result.stdout, 'stderr': result.stderr}
report = {'scope': 'Offline non-root chroot programs; not a boot, HAL or graphical qualification',
'engine': engine, 'uid': 1000, 'commands': results, 'sandboxQualified': False,
'passed': all(r['returncode'] == 0 and 'not found' not in r['stdout']
for r in results.values())}
screenshot = root/'home/vulpes/render.png'
if screenshot.is_file():
data = screenshot.read_bytes()
report['render'] = {'sha256': hashlib.sha256(data).hexdigest(),
'width': int.from_bytes(data[16:20], 'big'),
'height': int.from_bytes(data[20:24], 'big')}
report['passed'] &= (data.startswith(b'\x89PNG\r\n\x1a\n') and
report['render']['width'] == 360 and report['render']['height'] == 740)
else:
report['passed'] = False
path = root.parent/'chroot-check.json'
path.write_text(json.dumps(report, indent=2)+'\n')
print(json.dumps(report, indent=2), flush=True)
if not report['passed']:
raise SystemExit(1)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,151 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Bounded replacement of the display session; restore Phosh when the trial ends."""
import argparse
import json
import os
from pathlib import Path
import pwd
import shlex
import signal
import subprocess
import time
UNIT = 'tundra-display-trial.service'
DROPIN = Path('/run/systemd/system/android-service@hwcomposer.service.d/20-phosh.conf')
SERVICE = Path('/run/systemd/system')/UNIT
CONFIG = Path('/run/tundra-phoc-trial.ini')
def command(*args,check=True):
return subprocess.run(args,check=check,capture_output=True,text=True,timeout=45)
def restore(result):
"""Attempt every restoration step even if an earlier command times out."""
errors = []
def attempt(*args):
try:
return command(*args, check=False)
except (OSError, subprocess.SubprocessError) as error:
errors.append({'command': list(args), 'error': str(error)})
return None
attempt('systemctl', 'stop', UNIT)
journal = attempt('journalctl', '-u', UNIT, '--no-pager', '-n', '300')
result['journal'] = journal.stdout if journal else ''
for path in (SERVICE, DROPIN, CONFIG):
try:
path.unlink(missing_ok=True)
except OSError as error:
errors.append({'path': str(path), 'error': str(error)})
attempt('systemctl', 'daemon-reload')
attempt('systemctl', 'reset-failed', UNIT)
attempt('systemctl', 'start', 'phosh.service')
status = attempt('systemctl', 'is-active', 'phosh.service')
result['restoredPhosh'] = status is not None and status.returncode == 0
if errors:
result['cleanupErrors'] = errors
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--product',type=Path,required=True)
parser.add_argument('--seconds',type=int,default=180)
parser.add_argument('--root',type=Path,help='Run Phoc and Gaia from this separate runtime tree; borrow installed Android HAL services')
args=parser.parse_args()
if os.geteuid()!=0:
raise RuntimeError('The display service switch requires administrator privileges')
product=args.product.resolve()
expected=Path('/home/droidian/vulpes-tundra-tests')
if not product.is_relative_to(expected) or not (product/'trial/session.py').is_file():
raise ValueError('Expected an existing isolated trial product')
if command('getprop','ro.product.device').stdout.strip()!='sargo':
raise ValueError('Expected Sargo')
if not 30<=args.seconds<=600:
raise ValueError('Trial duration must be between 30 and 600 seconds')
if any(p.exists() for p in (DROPIN,SERVICE,CONFIG)):
raise RuntimeError('Previous runtime configuration present; restore it before another trial')
if command('systemctl','is-active','phosh.service',check=False).returncode:
raise RuntimeError('Phosh must be active as the known return session')
account=pwd.getpwnam('droidian')
root = args.root.resolve() if args.root else None
if root:
if not root.is_relative_to(expected) or not (root/'usr/bin/phoc').is_file():
raise ValueError('Expected a staged ARM64 runtime under the trial directory')
if not (root/'opt/vulpes/trial/session.py').is_file():
raise ValueError('Common product must be staged under /opt/vulpes in the trial root')
(root/'opt/vulpes/logs').mkdir(exist_ok=True)
os.chown(root/'opt/vulpes/logs',account.pw_uid,account.pw_gid)
child=shlex.join(['/usr/bin/python3',str(Path('/opt/vulpes') if root else product)+'/trial/session.py','--own-compositor','--hybris-egl'])
result={'scope':'Own compositor on installed Droidian; not an independent root filesystem',
'startedAt':time.time(),'seconds':args.seconds,'restoredPhosh':False}
if root:
result.update(scope='Phoc and Gaia in a separate ARM64 root; Android HAL and logind borrowed from installed Droidian',runtimeRoot=str(root))
def interrupted(signum,frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM,interrupted)
try:
DROPIN.parent.mkdir(exist_ok=True)
DROPIN.write_text('[Unit]\nBindsTo=\n')
CONFIG.write_text('[output:HWCOMPOSER-1]\nscale=3\n')
isolation = f'''RootDirectory={root}
MountAPIVFS=yes
PrivateNetwork=yes
BindPaths=/dev /run/user/{account.pw_uid}
BindReadOnlyPaths=/android/system /android/vendor /run/udev /run/dbus /run/systemd/sessions /run/systemd/seats /run/systemd/users /sys {CONFIG}
''' if root else ''
SERVICE.write_text(f'''[Unit]
Description=Temporary Vulpes display qualification
Requires=android-service@hwcomposer.service
After=android-service@hwcomposer.service systemd-user-sessions.service
Conflicts=phosh.service
[Service]
User=droidian
PAMName=login
WorkingDirectory=/home/droidian
Environment=XDG_RUNTIME_DIR=/run/user/{account.pw_uid}
Environment=XDG_SESSION_TYPE=wayland
Environment=XDG_CURRENT_DESKTOP=Vulpes
Environment=LANG=C.UTF-8
Environment=WLR_BACKENDS=hwcomposer,libinput
Environment=EGL_PLATFORM=hwcomposer
Environment=WLR_HWC_SKIP_VERSION_CHECK=1
{isolation}ExecStart={'/usr/bin/dbus-run-session -- ' if root else ''}/usr/bin/phoc --no-xwayland --socket=tundra-0 -C {CONFIG} -E {json.dumps(child)}
{'' if root else 'ExecStartPost=+chvt 7'}
TTYPath=/dev/tty7
TTYReset=yes
TTYVHangup=yes
StandardInput=tty-fail
StandardOutput={'append:'+str(root/'opt/vulpes/logs/compositor.log') if root else 'journal'}
StandardError=inherit
KillMode=control-group
TimeoutStopSec=20
RuntimeMaxSec={args.seconds+30}
''')
for p in (DROPIN,CONFIG,SERVICE):p.chmod(0o644)
command('systemctl','daemon-reload')
command('systemctl','stop','phosh.service')
command('systemctl','start',UNIT)
if root:
command('chvt','7')
print('Own compositor started; Phosh will be restored automatically.',flush=True)
deadline=time.monotonic()+args.seconds
while time.monotonic()<deadline:
if command('systemctl','is-active',UNIT,check=False).returncode:
raise RuntimeError('Dedicated compositor stopped unexpectedly')
time.sleep(2)
except Exception as e:
result['error']=str(e)
raise
finally:
restore(result)
result['finishedAt']=time.time()
report=(root/'opt/vulpes' if root else product)/'logs/compositor-trial.json'
report.parent.mkdir(exist_ok=True)
report.write_text(json.dumps(result,indent=2)+'\n')
os.chown(report,account.pw_uid,account.pw_gid)
print('Restored Phosh:',result['restoredPhosh'],flush=True)
if __name__=='__main__':
main()

View file

@ -0,0 +1,155 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Export package-owned runtime files for an offline ARM64 chroot experiment.
This is a reference snapshot, not a Debian installation or a bootable rootfs.
Package scripts and host configuration are deliberately not imported.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import subprocess
import tarfile
ROOT_PACKAGES = '''bash coreutils util-linux libc-bin python3 systemd systemd-sysv
udev dbus dbus-user-session libpam-systemd kbd phoc libhybris lxc lxc-android
halium-wrappers libgtk-3-0t64 libasound2t64 libdbus-glib-1-2 libxt6t64 libnss3
librsvg2-common shared-mime-info locales'''.split()
def installed_packages():
fields = ['binary:Package', 'Version', 'Architecture', 'db:Status-Abbrev',
'Depends', 'Pre-Depends', 'Provides', 'source:Package', 'source:Version']
output = subprocess.check_output(['dpkg-query', '-W', '-f',
'\t'.join('${'+f+'}' for f in fields)+'\n'], text=True)
return {p[0]: dict(zip(fields, p)) for line in output.splitlines()
if len(p := line.split('\t')) == len(fields) and p[3].startswith('ii')}
def dependency(token):
match = re.fullmatch(r'\s*([a-z0-9+.-]+)(?::([a-z0-9-]+))?'
r'\s*(?:\((<<|<=|=|>=|>>)\s*([^ )]+)\))?\s*', token)
if not match:
raise ValueError('Unsupported dependency: '+token)
return match.groups()
def closure(packages, roots):
providers = {}
for name, package in sorted(packages.items()):
base = name.split(':')[0]
providers.setdefault(base, []).append((name, package['Version']))
for token in package['Provides'].split(','):
if token.strip():
virtual, _, _, version = dependency(token)
providers.setdefault(virtual, []).append((name, version))
def resolve(expression):
for alternative in expression.split('|'):
name, arch, op, version = dependency(alternative)
candidates = sorted(providers.get(name, []),
key=lambda item: item[0].split(':')[0] != name)
for candidate, provided_version in candidates:
if arch not in (None, 'any', 'native', packages[candidate]['Architecture']):
continue
if op and (not provided_version or subprocess.run(
['dpkg', '--compare-versions', provided_version, op, version],
check=False).returncode):
continue
return candidate
raise ValueError('No installed provider: '+expression)
pending = [resolve(root) for root in roots]
result = {}
while pending:
name = pending.pop()
if name in result:
continue
package = dict(packages[name])
deps = [resolve(expr) for key in ('Pre-Depends', 'Depends')
for expr in package[key].split(',') if expr.strip()]
package['resolvedDependencies'] = deps
result[name] = package
pending.extend(deps)
return dict(sorted(result.items()))
def allowed(path):
parts = PurePosixPath(path).parts
return (len(parts) > 2 and parts[0] == '/' and '..' not in parts
and parts[1] in ('usr', 'bin', 'sbin', 'lib', 'lib64')
and parts[1:3] != ('usr', 'local'))
def export(output, packages):
output.mkdir(parents=True, exist_ok=False)
files = {}
for name in packages:
for path in subprocess.check_output(['dpkg-query', '-L', name], text=True).splitlines():
if allowed(path):
files.setdefault(path, []).append(name)
manifest = {'scope': __doc__, 'architecture': 'arm64', 'roots': ROOT_PACKAGES,
'packages': packages, 'files': {}, 'missing': []}
with tarfile.open(output/'runtime.tar', 'w') as tar:
for path, owners in sorted(files.items()):
source = Path(path)
if not source.exists() and not source.is_symlink():
manifest['missing'].append(path)
continue
if source.is_dir() and not source.is_symlink():
continue
info = tar.gettarinfo(path, arcname=path.lstrip('/'))
if not info.isfile() and not info.issym():
raise ValueError('Unexpected package file type: '+path)
info.uid = info.gid = 0
info.uname = info.gname = 'root'
info.mode &= ~0o6000 # No setuid/setgid programs in this reference snapshot.
record = {'packages': owners, 'mode': info.mode}
if info.isfile():
with source.open('rb') as stream:
record['sha256'] = hashlib.file_digest(stream, 'sha256').hexdigest()
stream.seek(0)
tar.addfile(info, stream)
else:
record['symlink'] = info.linkname
tar.addfile(info)
manifest['files'][path] = record
with (output/'runtime.tar').open('rb') as stream:
manifest['archiveSha256'] = hashlib.file_digest(stream, 'sha256').hexdigest()
(output/'manifest.json').write_text(json.dumps(manifest, indent=2)+'\n')
print(json.dumps({'packages': len(packages), 'files': len(manifest['files']),
'missing': manifest['missing'], 'archiveSha256': manifest['archiveSha256']}))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--with-network', action='store_true',
help='Include NetworkManager and wpa_supplicant, without saved connections')
parser.add_argument('--with-hardware', action='store_true',
help='Include oFono, PulseAudio and gst-droid, without personal configuration')
args = parser.parse_args()
if os.geteuid() == 0:
parser.error('Run as the unprivileged device user')
if subprocess.check_output(['dpkg', '--print-architecture'], text=True).strip() != 'arm64':
parser.error('Expected the ARM64 reference device')
if args.with_network:
ROOT_PACKAGES.extend(['network-manager', 'wpasupplicant'])
if args.with_hardware:
ROOT_PACKAGES.extend(['ofono', 'pulseaudio', 'pulseaudio-utils',
'pulseaudio-modules-droid-jb2q', 'pulseaudio-modules-droid-hidl',
'gstreamer1.0-droid', 'bluebinder', 'libgbinder-radio',
'libgbinder-tools',
'libofonobinderpluginext', 'ofono-binder-plugin',
'ofono-configs-binder-common', 'ofono-scripts', 'ofono2mm',
'pulseaudio-config-droid', 'droidian-camera',
'qt5-cameraplugin-aal', 'libhybris'])
packages = closure(installed_packages(), ROOT_PACKAGES)
export(args.output, packages)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,39 @@
/* SPDX-License-Identifier: MPL-2.0
* Local GLVND adapter for Droidian libhybris b22d041 (Wayland only).
* That revision passes its wrapper EGLDisplay to Android eglGetConfigAttrib.
* All other entry points retain the vendor's implementation. The launcher
* must verify the exact vendor library hash before enabling this adapter.
*/
#define EGL_NO_X11
#include <glvnd/libeglabi.h>
extern void *dlopen(const char *, int);
extern void *dlsym(void *, const char *);
extern int strcmp(const char *, const char *);
static void *(*vendor_proc)(const char *);
static EGLBoolean (*vendor_config)(EGLDisplay, EGLConfig, EGLint, EGLint *);
static EGLBoolean config_attrib(EGLDisplay display, EGLConfig config,
EGLint attribute, EGLint *value) {
/* ws.h at b22d041: _EGLDisplay begins with the Android EGLDisplay dpy.
* GLVND has already checked that this display belongs to this vendor.
*/
EGLDisplay android_display = *(EGLDisplay *)display;
return vendor_config(android_display, config, attribute, value);
}
static void *get_proc(const char *name) {
if (!strcmp(name, "eglGetConfigAttrib")) return (void *)config_attrib;
return vendor_proc(name);
}
EGLBoolean __egl_Main(uint32_t version, const __EGLapiExports *exports,
__EGLvendorInfo *vendor, __EGLapiImports *imports) {
void *library = dlopen("libEGL_libhybris.so.0", 2);
if (!library) return EGL_FALSE;
EGLBoolean (*entry)(uint32_t, const __EGLapiExports *, __EGLvendorInfo *,
__EGLapiImports *) = dlsym(library, "__egl_Main");
if (!entry || !entry(version, exports, vendor, imports)) return EGL_FALSE;
vendor_proc = imports->getProcAddress;
vendor_config = vendor_proc("eglGetConfigAttrib");
if (!vendor_config) return EGL_FALSE;
imports->getProcAddress = get_proc;
return EGL_TRUE;
}

View file

@ -0,0 +1,11 @@
--- a/hybris/egl/egl.c
+++ b/hybris/egl/egl.c
@@ -638,7 +638,7 @@
EGLBoolean ret;
HYBRIS_TRACE_BEGIN("hybris-egl", "eglGetConfigAttrib", "");
HYBRIS_DLSYSM(egl, &_eglGetConfigAttrib, "eglGetConfigAttrib");
- ret = (*_eglGetConfigAttrib)(dpy, config, attribute, value);
+ ret = (*_eglGetConfigAttrib)(hybris_egl_get_real_display(dpy), config, attribute, value);
ws_getConfigAttrib(&dpy, &config, &attribute, value);
HYBRIS_TRACE_END("hybris-egl", "eglGetConfigAttrib", "");
return ret;

View file

@ -0,0 +1,6 @@
{
"vendorLibrary": "/usr/lib/aarch64-linux-gnu/libEGL_libhybris.so.0",
"vendorSha256": "e6e949e478f8f96718afe07e4e0c50f524afb6f587aed89304eb7d083ba3dcb5",
"sourceRevision": "b22d041aaf97c5e2bf7ea9a4281fdf9b73edd805",
"upstreamSource": "https://github.com/droidian/libhybris/blob/b22d041aaf97c5e2bf7ea9a4281fdf9b73edd805/hybris/egl/egl.c"
}

View file

@ -0,0 +1,18 @@
#!/bin/sh
# SPDX-License-Identifier: MPL-2.0
# Run inside the staged root before starting its graphical session.
set -eu
/sbin/ldconfig
/usr/lib/aarch64-linux-gnu/glib-2.0/glib-compile-schemas /usr/share/glib-2.0/schemas
/usr/lib/aarch64-linux-gnu/gdk-pixbuf-2.0/gdk-pixbuf-query-loaders --update-cache
/usr/bin/update-mime-database /usr/share/mime
/usr/bin/python3 - <<'PY'
import gzip
from pathlib import Path
for name in ('UTF-8', 'ANSI_X3.4-1968'):
compressed = Path('/usr/share/i18n/charmaps')/(name+'.gz')
if compressed.is_file():
compressed.with_suffix('').write_bytes(gzip.decompress(compressed.read_bytes()))
PY
/usr/bin/localedef --no-archive -i C -f UTF-8 C.UTF-8
test -s /usr/lib/aarch64-linux-gnu/gdk-pixbuf-2.0/2.10.0/loaders.cache

View file

@ -0,0 +1,29 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Read hardware service topology without SIM identifiers, messages or network secrets."""
import json
import subprocess
import time
from pathlib import Path
def main():
commands={
'services':['systemctl','is-active','NetworkManager','ModemManager','ofono','phosh'],
'networkDevices':['nmcli','-t','-f','DEVICE,TYPE,STATE','device','status'],
'modemObjects':['busctl','--system','tree','org.freedesktop.ModemManager1'],
'ofonoObjects':['busctl','--system','tree','org.ofono'],
'audioPackages':['dpkg-query','-W','-f=${binary:Package}\t${Version}\n','pulseaudio','pulseaudio-modules-droid-hidl','callaudiod','ofono-binder-plugin','modemmanager','droidian-camera'],
}
report={'recordedAtUnix':time.time(),'scope':'Read-only service topology; no SIM identifiers, message content or saved connection profiles','commands':{}}
for name,command in commands.items():
try:
p=subprocess.run(command,capture_output=True,text=True,timeout=15)
report['commands'][name]={'returncode':p.returncode,'stdout':p.stdout,'stderr':p.stderr}
except (OSError,subprocess.TimeoutExpired) as error:
report['commands'][name]={'error':str(error)}
report['cameraNodes']=[p.name for p in Path('/dev').glob('video*')]
print(json.dumps(report,indent=2))
if __name__=='__main__':main()

View file

@ -0,0 +1,58 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Read the installed display/container adaptation; exclude user data and credentials."""
import datetime
import hashlib
import json
from pathlib import Path
import subprocess
def run(args):
result=subprocess.run(args,capture_output=True,text=True,timeout=30)
return {'command':args,'returncode':result.returncode,'stdout':result.stdout,'stderr':result.stderr}
def main():
device=run(['getprop','ro.product.device'])
if device['stdout'].strip()!='sargo':
raise RuntimeError('Expected Sargo')
report={'recordedAt':datetime.datetime.now(datetime.timezone.utc).isoformat(),
'device':'sargo','readOnly':True,'files':{},'commands':{}}
commands={
'kernel':['uname','-a'],
'units':['systemctl','cat','android-mount.service','lxc@android.service',
'android-service@hwcomposer.service','phosh.service'],
'compositor':['phoc','--version'],
'packages':['dpkg-query','-W','-f','${binary:Package}\t${Version}\t${Architecture}\n'],
'phocLibraries':['ldd','/usr/bin/phoc'],
'eglLibraries':['ldd','/usr/lib/aarch64-linux-gnu/libEGL_libhybris.so.0'],
'mounts':['findmnt','--json','-o','SOURCE,TARGET,FSTYPE,OPTIONS'],
}
for name,command in commands.items():report['commands'][name]=run(command)
paths=['/var/lib/lxc/android/config','/var/lib/lxc/android/pre-start.sh',
'/usr/lib/lxc-android/mount-android','/usr/lib/lxc-android/lxc-android-notify',
'/usr/lib/lxc-android/lxc-android-stop','/usr/sbin/mount-android.sh',
'/usr/lib/halium-wrappers/android-service.sh','/etc/phosh/phoc.ini',
'/usr/share/phosh/phoc.ini','/etc/deviceinfo/default.yaml',
'/etc/pam.d/phosh','/etc/pam.d/login',
'/usr/share/doc/android-system-google-sargo/copyright',
'/usr/share/doc/libhybris/copyright','/usr/share/doc/phoc/copyright',
'/usr/share/doc/lxc-android/copyright']
paths += [str(p) for p in Path('/etc/deviceinfo/devices').glob('*.yaml')]
for path in paths:
p=Path(path)
try:
data=p.read_bytes()
report['files'][path]={'sha256':hashlib.sha256(data).hexdigest(),'bytes':len(data),
'text':data.decode(),'owner':run(['dpkg-query','-S',path])['stdout'].strip()}
except (OSError,UnicodeError) as error:
report['files'][path]={'error':str(error)}
image=Path('/var/lib/lxc/android/android-rootfs.img')
with image.open('rb') as stream:
report['androidSystemImage']={'path':str(image),'bytes':image.stat().st_size,
'sha256':hashlib.file_digest(stream,'sha256').hexdigest()}
print(json.dumps(report,indent=2))
if __name__=='__main__':main()

View file

@ -0,0 +1,43 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Install the common product and generated session configuration in a staged root."""
import argparse
import os
from pathlib import Path
import shutil
def prepare(root, product, uid):
if not (root.parent/(root.name+'-stage.json')).is_file():
raise ValueError('Expected a staged runtime reference')
target = root/'opt/vulpes'
for name in ('assets','host','services','adapters','overrides','gaia','tools',
'tests','engines','trial','engine-lock.json','release.json'):
source, destination = product/name, target/name
if source.is_dir():
shutil.copytree(source,destination,dirs_exist_ok=True,symlinks=True,
ignore=shutil.ignore_patterns('__pycache__'))
else:
shutil.copy2(source,destination)
(target/'logs').mkdir(exist_ok=True)
(root/'home/droidian').mkdir(exist_ok=True)
(root/'etc/passwd').write_text(f'root:x:0:0:root:/root:/bin/bash\ndroidian:x:{uid}:{uid}:Vulpes trial:/home/droidian:/bin/bash\n')
(root/'etc/group').write_text(f'root:x:0:\ndroidian:x:{uid}:\n')
for name in ('system','vendor'):
path=root/name
if not path.is_symlink() and not path.exists():path.symlink_to('android/'+name)
# D-Bus uses /usr/share/dbus-1/*.conf. Do not alias those files into /etc:
# the packaged defaults include the optional /etc override themselves.
(root/'etc/dbus-1').mkdir(exist_ok=True)
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--root',type=Path,required=True)
parser.add_argument('--product',type=Path,required=True)
args=parser.parse_args()
if os.getuid()==0:parser.error('Prepare as the unprivileged trial user')
prepare(args.root.resolve(),args.product.resolve(),os.getuid())
if __name__=='__main__':main()

View file

@ -0,0 +1,614 @@
// SPDX-License-Identifier: MPL-2.0
// Runs only in the isolated Droidian trial, without a remote debugger.
import {setTimeout, setInterval, clearInterval} from 'resource://gre/modules/Timer.sys.mjs';
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
export async function run(win) {
dump('TUNDRA_DROIDIAN_PROBE started\n');
const interactive = Services.env.get('TUNDRA_DIAGNOSTICS') === '1';
const root = Services.env.get('VULPES_WORKSPACE_ROOT');
const report = {passed:false, mode:Services.env.get('TUNDRA_HEADLESS')==='1'?'headless':'wayland',
ownCompositor:Services.env.get('TUNDRA_OWN_COMPOSITOR')==='1', checks:{}, scope:interactive?'interactive':'startup-only', engine:{
version:Services.appinfo.platformVersion, buildID:Services.appinfo.appBuildID,
ABI:Services.appinfo.XPCOMABI, OS:Services.appinfo.OS}};
report.bootId = Services.env.get('TUNDRA_BOOT_ID');
const listener={observe(message) {
const text=message.message || String(message);
if(text.includes('.localhost:8765') || text.includes('resource://vulpes/'))
dump('TUNDRA_CONSOLE '+text+'\n');
}};
Services.console.registerListener(listener);
if (Services.env.get('VULPES_TUNDRA') === '1') {
let checking = false;
const networkTimer = setInterval(async () => {
if (checking) return;
checking = true;
try {
const state = await IOUtils.readJSON('/run/tundra-hardware/status.json');
if (state.wifi?.state !== 'connected') return;
clearInterval(networkTimer);
const response = await win.fetch('https://example.org/', {
signal: win.AbortSignal.timeout(20000), cache: 'no-store',
});
const body = await response.text();
await IOUtils.writeJSON(`${root}/logs/network-gecko.json`, {
passed: response.ok && body.includes('Example Domain'),
status: response.status, gecko: Services.appinfo.platformVersion,
url: 'https://example.org/', certificateChecksDisabled: false,
});
} catch (error) {
await IOUtils.writeJSON(`${root}/logs/network-gecko.json`, {passed:false,error:String(error)});
} finally { checking = false; }
}, 5000);
win.addEventListener('unload', () => clearInterval(networkTimer), {once:true});
}
function contextState(context) {
return context ? {id:context.id,url:context.currentWindowGlobal?.documentURI.spec,
children:Array.from(context.children,contextState)} : null;
}
function hostState() {
const browser=win.document.querySelector('browser');
return {ready:win.hostReady,visibility:win.document.visibilityState,
fission:Services.appinfo.fissionAutostart,
fissionPref:Services.prefs.getBoolPref('fission.autostart',false),
proxy:Services.prefs.getIntPref('network.proxy.type',-1),
browser:browser ? {src:browser.getAttribute('src'),uri:browser.currentURI?.spec,
loading:browser.webProgress?.isLoadingDocument,
remote:browser.getAttribute('remote'),remoteType:browser.remoteType,
context:contextState(browser.browsingContext),
active:browser.docShellIsActive,renderLayers:browser.renderLayers} : null};
}
let previousState;
const trace=setInterval(()=>{
const state=JSON.stringify(hostState());
if(state!==previousState) {dump('TUNDRA_HOST_STATE '+state+'\n');previousState=state;}
},5000);
win.fetch('http://system.localhost:8765/index.html', {signal:win.AbortSignal.timeout(10000)})
.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') {
function find(context) {
if(context.currentWindowGlobal?.documentURI.spec.startsWith(`http://${app}.localhost:8765/`)) return context;
for(const child of context.children) {const found=find(child); if(found) return found;}
return null;
}
const context=win.document.querySelector('browser')?.browsingContext;
const child=context && find(context);
if(!child) return null;
return Promise.race([
child.currentWindowGlobal.getActor('VulpesProbe').sendQuery(message,code),
sleep(30000).then(()=>{throw new Error('Content actor did not answer within 30 seconds');})
]);
}
async function wait(app, code) {
dump(`TUNDRA_DROIDIAN_PROBE waiting ${app}: ${code}\n`);
let last;
const deadline=Date.now()+90000;
while(Date.now()<deadline) {
try {last=await evaluate(app,code); if(last) return last;} catch(e) {last=String(e);}
await sleep(500);
}
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);
}
try {
const lock=await IOUtils.readJSON(`${root}/engine-lock.json`);
if(report.engine.version!==lock.version || !report.engine.ABI.startsWith('aarch64'))
throw new Error('Unexpected engine or architecture');
ChromeUtils.registerWindowActor('VulpesProbe',{
child:{esModuleURI:'resource://vulpes/tests/ProbeChild.sys.mjs'},
allFrames:true,safeForUntrustedWebProcess:true});
report.checks.system=await wait('system',"return document.body?.getAttribute('ready-state')==='fullyLoaded';");
report.checks.home=await wait('homescreen',"return document.readyState==='complete' && document.body.innerText.length>0;");
report.checks.visible=await wait('system',"return document.visibilityState==='visible';");
report.checks.bootLogoHidden=await wait('system',"const logo=document.getElementById('os-logo');return !logo || getComputedStyle(logo).opacity==='0';");
if (Services.env.get('TUNDRA_PERSISTENT') === '1') {
const token = Services.uuid.generateUUID().toString();
report.persistence = await evaluate('system', `
const key='vulpes.tundra.persistence-proof';
const previous=await VulpesCompat.call('settings.get',{key});
if (!previous.found) await VulpesCompat.call('settings.set',{values:{[key]:${JSON.stringify(token)}}});
const current=await VulpesCompat.call('settings.get',{key});
return {restored:previous.found,value:current.value,found:current.found};
`);
report.checks.settingsStorage = report.persistence.found && typeof report.persistence.value === 'string';
}
if (interactive) {
for(const app of ['settings','clock']) {
await evaluate('system',`await VulpesCompat.call('apps.launch',{manifestURL:'http://${app}.localhost:8765/manifest.webapp'});return true;`);
report.checks[app]=await wait(app,"return document.readyState==='complete' && document.body.innerText.length>20;");
if (app === 'settings') {
report.checks.noDebugSettings = await evaluate('settings', "return !document.getElementById('vulpes-open-settings') && !document.getElementById('vulpes-test-notification');");
}
}
win.document.getElementById('home')?.click();
report.checks.returnHome=await wait('system',"return !!document.querySelector('.appWindow.active[transition-state=opened] iframe[src*=\"homescreen.localhost\"]');");
report.input=await evaluate('homescreen',"return {maxTouchPoints:navigator.maxTouchPoints,touchEvents:'ontouchstart' in window,coarse:matchMedia('(pointer: coarse)').matches};");
report.nativeTouch=Services.ppmm.sharedData.get('vulpes:native-touch')===true;
report.frameTiming=await evaluate('homescreen',`return await new Promise(resolve=>{
const target=Array.from(document.querySelectorAll('*')).find(el=>el.scrollHeight>el.clientHeight+100 && ['auto','scroll'].includes(getComputedStyle(el).overflowY));
const original=target?.scrollTop || 0, samples=[]; let start,previous;
function frame(now) {
if(start===undefined)start=now;
if(previous!==undefined)samples.push(now-previous);
previous=now;
if(target)target.scrollTop=original+Math.min(300,(now-start)*.15);
if(now-start<4000){requestAnimationFrame(frame);return;}
if(target)target.scrollTop=original;
samples.sort((a,b)=>a-b);
resolve({durationMs:now-start,frames:samples.length,fps:samples.length*1000/(now-start),medianMs:samples[Math.floor(samples.length*.5)],p95Ms:samples[Math.floor(samples.length*.95)],scrollTarget:target?.id||target?.tagName||null});
}
requestAnimationFrame(frame);
});`);
report.fonts = await evaluate('homescreen', `await document.fonts.ready; return {loaded:Array.from(document.fonts,f=>({family:f.family,weight:f.weight,status:f.status})),icon:document.querySelector('gaia-app-icon')?.outerHTML,shadow:document.querySelector('gaia-app-icon')?.shadowRoot?.innerHTML,images:Array.from(document.querySelectorAll('gaia-app-icon')).slice(0,12).map(i=>{const img=i.shadowRoot?.querySelector('img');const title=i.shadowRoot?.querySelector('#subtitle');return {id:i.dataset.identifier,src:img?.src,complete:img?.complete,naturalWidth:img?.naturalWidth,width:img?.clientWidth,height:img?.clientHeight,font:title&&getComputedStyle(title).font};}),styles:Array.from(document.styleSheets,s=>s.href)};`);
report.viewport={width:win.innerWidth,height:win.innerHeight,devicePixelRatio:win.devicePixelRatio};
report.checks.homeIcons = await wait('homescreen', `const icons=Array.from(document.querySelectorAll('gaia-app-icon')).slice(0,12);return icons.length===12 && icons.every(i=>{const image=i.shadowRoot?.querySelector('img');return image?.complete && image.naturalWidth>0 && image.clientWidth>0;});`);
report.checks.firaLoaded = report.fonts.loaded.some(f=>f.family==='FiraSans' && f.weight==='400' && f.status==='loaded');
const surface=()=>{
const button=win.document.getElementById('home'), style=win.getComputedStyle(button);
return {home:win.document.documentElement.hasAttribute('home-active'),
bottom:win.document.documentElement.hasAttribute('home-at-bottom'),
background:style.backgroundImage,color:style.backgroundColor};
};
async function waitSurface(test) {
for(let i=0;i<100;i++) {const s=surface();if(test(s))return s;await sleep(100);}
throw Error('Unexpected Home surface: '+JSON.stringify(surface()));
}
await evaluate('homescreen', `document.querySelector('#apps-panel > .scrollable').scrollTop=0;return true;`);
report.navigation={top:await waitSurface(s=>s.home && !s.bottom && s.background.includes('linear-gradient'))};
win.InspectorUtils.addPseudoClassLock(win.document.getElementById('home'),':active');
report.navigation.pressed=surface();
win.InspectorUtils.removePseudoClassLock(win.document.getElementById('home'),':active');
await evaluate('homescreen', `const p=document.querySelector('#apps-panel > .scrollable');p.scrollTop=p.scrollHeight;return true;`);
report.navigation.bottom=await waitSurface(s=>s.bottom && s.background==='none' && s.color==='rgba(0, 0, 0, 0)');
await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://settings.localhost:8765/manifest.webapp'});return true;`);
report.navigation.application=await waitSurface(s=>!s.home && s.background==='none' && s.color==='rgb(18, 35, 50)');
win.document.getElementById('home').click();
await waitSurface(s=>s.home);
await evaluate('homescreen', `document.querySelector('#apps-panel > .scrollable').scrollTop=0;return true;`);
report.navigation.returnHome=await waitSurface(s=>s.home && !s.bottom && s.background.includes('linear-gradient'));
report.checks.navigation=report.navigation.top.background===report.navigation.pressed.background;
if (Services.env.get('VULPES_TUNDRA') === '1') {
report.hardware = await evaluate('system', "return await VulpesCompat.call('platform.snapshot', {});");
report.capabilities = await evaluate('system', "return await VulpesCompat.call('platform.capabilities', {});");
report.checks.nativeIdentity = report.capabilities.platform === 'tundra' && report.capabilities.adapter.device === 'sargo';
report.checks.batteryRead = report.hardware.available && Number.isFinite(report.hardware.battery?.level);
await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://settings.localhost:8765/manifest.webapp'});return true;`);
await wait('settings', "return !!document.querySelector('a[href=\"#battery\"]');");
await evaluate('settings', "document.querySelector('a[href=\"#battery\"]').click();return true;");
try {
report.checks.hardwarePanel = await wait('settings', "const d=document.getElementById('vulpes-hardware-panel');return !!d?.open && d.innerText.includes('%');");
await evaluate('settings', "document.getElementById('vulpes-hardware-panel').close();return true;");
} catch(error) {
report.hardwarePanelNote = String(error);
report.checks.hardwarePanel = null;
}
win.document.getElementById('home').click();
await waitSurface(s=>s.home);
}
} else {
// Observe the normal boot without opening apps or moving the home screen.
report.startup = win.vulpesStartup;
report.input = await evaluate('homescreen', "return {maxTouchPoints:navigator.maxTouchPoints,touchEvents:'ontouchstart' in window};");
report.nativeTouch = Services.ppmm.sharedData.get('vulpes:native-touch') === true;
report.samples = [];
for (let i = 0; i < 5; i++) {
report.samples.push(await evaluate('system', `return {
app:document.querySelector('.appWindow.active iframe')?.src,
locked:!!window.lockScreen?.locked && document.getElementById('screen').classList.contains('locked'),
width:innerWidth,height:innerHeight
};`));
await sleep(1000);
}
report.checks.idleStartSurface = report.samples.every(s => s?.locked || s?.app?.startsWith('http://homescreen.localhost:8765/'));
report.checks.stableViewport = report.samples.every(s => s?.width === report.samples[0]?.width && s?.height === report.samples[0]?.height);
report.window = {fullscreen:win.fullScreen, hidechrome:win.document.documentElement.getAttribute('hidechrome')};
if (Services.env.get('VULPES_TUNDRA') === '1') {
report.hardware = await evaluate('system', "return await VulpesCompat.call('platform.snapshot', {});");
report.capabilities = await evaluate('system', "return await VulpesCompat.call('platform.capabilities', {});");
report.checks.nativeIdentity = report.capabilities.platform === 'tundra';
report.checks.batteryRead = report.hardware.available && Number.isFinite(report.hardware.battery?.level);
report.checks.callScreenReady = await evaluate('system',
"return !!navigator.mozTelephony && !!document.querySelector('.callscreenWindow') && Array.isArray(await VulpesCompat.call('platform.calls', {}));");
try {
const { tundraRadio } = ChromeUtils.importESModule('resource://vulpes/host/Tundra.sys.mjs');
const state = await tundraRadio('status');
const modem = state.modem?.[0] || {};
const registration = state.registration?.[0] || {};
report.radio = {powered:modem.Powered?.data, online:modem.Online?.data,
state:registration.Status?.data, technology:registration.Technology?.data};
} catch (error) { report.radio = {error:String(error)}; }
}
}
// 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});
const controlsMarker = `${root}/logs/test-controls`;
if (await IOUtils.exists(controlsMarker)) {
await IOUtils.remove(controlsMarker);
const controls = {bootId:report.bootId};
const press = async key => {
await evaluate('system', `dispatchEvent(new CustomEvent('softwareButtonEvent',{detail:{type:'${key}-button-press'}})); return true;`);
await sleep(120);
await evaluate('system', `dispatchEvent(new CustomEvent('softwareButtonEvent',{detail:{type:'${key}-button-release'}})); return true;`);
await sleep(1000);
};
const brightness = async () => (await IOUtils.readUTF8('/sys/class/backlight/panel0-backlight/brightness')).trim();
controls.beforeBrightness = await brightness();
try {
await press('sleep'); controls.offBrightness = await brightness();
await press('sleep'); controls.onBrightness = await brightness();
await evaluate('system', `dispatchEvent(new CustomEvent('softwareButtonEvent',{detail:{type:'sleep-button-press'}}));return true;`);
await sleep(1000);
controls.powerMenu = await evaluate('system', `return {visible:document.getElementById('sleep-menu').classList.contains('visible'),text:document.getElementById('sleep-menu').innerText};`);
await evaluate('system', `dispatchEvent(new CustomEvent('softwareButtonEvent',{detail:{type:'sleep-button-release'}}));dispatchEvent(new Event('home'));return true;`);
await press('volume-up');
controls.volume = await evaluate('system', `return {visible:document.getElementById('volume').classList.contains('visible'),channel:document.getElementById('volume').dataset.channel};`);
await press('volume-down');
controls.battery = await evaluate('system', `return {level:navigator.battery?.level,charging:navigator.battery?.charging,icon:document.getElementById('statusbar-battery')?.outerHTML};`);
} catch (error) { controls.error = String(error); }
finally { await evaluate('system', 'navigator.mozPower.screenEnabled=true; return true;'); }
const restoration = {bootId:report.bootId};
try {
restoration.date = await evaluate('system', "return !!document.querySelector('#statusbar-operator')?.textContent;");
restoration.lockIcons = await evaluate('system', "return getComputedStyle(document.querySelector('#lockscreen-area-unlock > div')).backgroundImage.includes('lockscreen_unlock.png');");
const content = win.document.getElementById('screen').getBoundingClientRect();
const home = win.document.getElementById('home').getBoundingClientRect();
restoration.navigationReserved = content.bottom <= home.top + 1;
await evaluate('system', "lockScreen.unlock(true);return true;");
await sleep(1000);
await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://settings.localhost:8765/manifest.webapp'});return true;");
await wait('settings', "return document.readyState==='complete' && typeof require==='function' && require.defined('modules/settings_panel');");
restoration.settings = await wait('settings', "return !document.getElementById('vulpes-platform-settings') && !!document.querySelector('#root a[href=\"#wifi\"]');");
await evaluate('settings', "require(['modules/settings_service'],s=>s.navigate('battery'));return true;");
restoration.batteryLabel = await wait('settings', "const t=document.getElementById('battery-level')?.textContent;return !!t && /[0-9]/.test(t) && !t.includes('{{');");
restoration.wifiAPI = await evaluate('settings', "return !!navigator.mozWifiManager;");
await evaluate('system', "await VulpesCompat.call('settings.set',{values:{'wifi.enabled':true}});return true;");
await wait('settings', "return navigator.mozWifiManager.enabled;");
if (restoration.wifiAPI) {
controls.wifiTransport = await evaluate('settings', "try {const r=await VulpesCompat.call('platform.wifi',{operation:'scan'});return {ok:true,count:r.networks.length,enabled:r.enabled};} catch(e) {return {ok:false,name:e.name,message:e.message};}");
const networks = await evaluate('settings', "return (await navigator.mozWifiManager.getNetworks()).length;");
restoration.wifiScan = Number.isInteger(networks);
await evaluate('settings', "require(['modules/settings_service'],s=>s.navigate('wifi'));return true;");
restoration.wifiPanel = await wait('settings', "return !!document.querySelector('#wifi .wifi-availableNetworks') && !document.querySelector('#wifi [data-vulpes-unavailable]');");
restoration.wifiResults = await wait('settings', "const list=document.querySelector('#wifi .wifi-availableNetworks');return list?.dataset.state==='ready' && list.querySelectorAll('li:not([data-state])').length>0;");
restoration.wifiNoSpinner = await evaluate('settings', "return !document.querySelector('#wifi .wifi-availableNetworks [data-vulpes-unavailable]');");
}
restoration.passed = Object.entries(restoration).filter(([key])=>key!=='bootId').every(([,value])=>value===true);
} catch (error) { restoration.passed=false; restoration.error=String(error); }
finally {
win.document.getElementById('home').click();
await evaluate('system', "if (lockScreen.enabled) lockScreen.lock(true);return true;");
}
// Exercise the original quick-settings control, not only its broker.
try {
const {tundraWifi}=ChromeUtils.importESModule('resource://vulpes/host/Tundra.sys.mjs');
await evaluate('system', "lockScreen.unlock(true);ScreenManager.turnScreenOn(true);return true;");
await sleep(1000);
const before=(await tundraWifi({operation:'status'})).enabled;
const toggle=async wanted=>{
await evaluate('system', "document.getElementById('quick-settings-wifi').click();return true;");
const deadline=Date.now()+10000;
while(Date.now()<deadline) {
if((await tundraWifi({operation:'status'})).enabled===wanted) return true;
await sleep(200);
}
throw Error('Quick Wi-Fi control did not change radio state');
};
try {await toggle(!before);controls.wifiToggle=true;}
finally {
if((await tundraWifi({operation:'status'})).enabled!==before) await toggle(before);
}
win.document.getElementById('home').click();await sleep(1000);
} catch(error) {controls.quickControlsError=String(error);}
controls.restoration=restoration;
const brightnessCheck = {passed:false};
try {
await evaluate('system', "lockScreen.unlock(true);ScreenManager.turnScreenOn(true);return true;");
await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://settings.localhost:8765/manifest.webapp'});return true;");
await evaluate('settings', "require(['modules/settings_service'],s=>s.navigate('display'));return true;");
await wait('settings', "return !!document.querySelector('#display.current .brightness-manual input');");
const old = await evaluate('system', "return ScreenManager._userBrightness;");
const set = async value => {
await evaluate('settings', `const input=document.querySelector('#display .brightness-manual input');input.value=${value};input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}));return true;`);
const expected = Math.round(value * Number(await IOUtils.readUTF8('/sys/class/backlight/panel0-backlight/max_brightness')));
const start=Date.now();
let measured;
do {
await sleep(100);
measured=Number(await IOUtils.readUTF8('/sys/class/backlight/panel0-backlight/brightness'));
if (Math.abs(measured-expected)<=3) {brightnessCheck.lastSettleMs=Date.now()-start;return measured;}
} while (Date.now()-start<5000);
throw Error(`Backlight did not reach ${expected}: ${measured}`);
};
try {brightnessCheck.low=await set(0.35);brightnessCheck.high=await set(0.8);brightnessCheck.passed=brightnessCheck.low>0 && brightnessCheck.high>brightnessCheck.low;}
finally {await set(Number.isFinite(old)?old:0.8);}
} catch(error) {brightnessCheck.error=String(error);}
finally {win.document.getElementById('home').click();}
controls.brightness=brightnessCheck;
await IOUtils.writeJSON(`${root}/logs/controls.json`, controls);
}
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;
return Array.from(document.fonts, f => ({family:f.family,weight:f.weight,style:f.style,status:f.status}));
`);
report.checks.firaLoaded = report.textFonts.some(f => f.family === 'FiraSans' && f.weight === '400' && f.status === 'loaded');
try {
report.renderedFonts = {
system: await evaluate('system', null, 'RenderedFonts'),
homescreen: await evaluate('homescreen', null, 'RenderedFonts'),
};
} catch (error) { report.renderedFontsError = String(error); }
try {
report.graphics=Cc['@mozilla.org/gfx/info;1'].getService(Ci.nsIGfxInfo).getFeatures();
} catch(error) {report.graphicsError=String(error);}
await sleep(1500);
try {
const bitmap=await win.browsingContext.currentWindowGlobal.drawSnapshot(
new win.DOMRect(0,0,win.innerWidth,win.innerHeight),1,'white');
const canvas=win.document.createElementNS('http://www.w3.org/1999/xhtml','canvas');
canvas.width=bitmap.width;canvas.height=bitmap.height;
canvas.getContext('2d').drawImage(bitmap,0,0);bitmap.close();
const blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));
await IOUtils.write(`${root}/logs/home.png`,new Uint8Array(await blob.arrayBuffer()));
report.screenshot='home.png';
} catch(error) {report.screenshotError=String(error);}
report.passed = Object.values(report.checks).every(value => value === true);
} catch(error) {
report.error=String(error)+'\n'+error.stack;
report.contexts=contextState(win.browsingContext);
report.host=hostState();
try {report.systemState=await evaluate('system',"return {ready:document.readyState,visibility:document.visibilityState,state:document.body?.getAttribute('ready-state'),text:document.body?.innerText.slice(0,1000)};");}
catch(stateError) {report.stateError=String(stateError);}
}
Services.console.unregisterListener(listener);
clearInterval(trace);
await IOUtils.writeJSON(`${root}/logs/test.json`,report);
dump('TUNDRA_DROIDIAN_TEST '+JSON.stringify(report)+'\n');
}

208
tundra/droidian/session.py Normal file
View file

@ -0,0 +1,208 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Run an isolated Vulpes window inside the existing Droidian Wayland session."""
import argparse
import fcntl
import importlib.util
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import sys
import time
ROOT = Path(__file__).resolve().parents[1]
def drop_capabilities():
"""The compositor may inherit hardware capabilities; Gaia must not keep them."""
import ctypes
libc=ctypes.CDLL(None,use_errno=True)
if libc.prctl(47,4,0,0,0): # PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL
raise OSError(ctypes.get_errno(),'clear ambient capabilities')
class Header(ctypes.Structure):
_fields_=[('version',ctypes.c_uint32),('pid',ctypes.c_int)]
class Data(ctypes.Structure):
_fields_=[('effective',ctypes.c_uint32),('permitted',ctypes.c_uint32),('inheritable',ctypes.c_uint32)]
if libc.capset(ctypes.byref(Header(0x20080522,0)),(Data*2)()):
raise OSError(ctypes.get_errno(),'drop process capabilities')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--hybris-egl', action='store_true', help='Use the hash-pinned local libhybris EGL correction')
parser.add_argument('--software', action='store_true', help='Use software WebRender for EGL driver diagnostics')
parser.add_argument('--headless', action='store_true', help='Test Gecko/Gaia without the locked physical display')
parser.add_argument('--no-forkserver', action='store_true', help='Diagnose process creation on the old device kernel')
parser.add_argument('--trace-navigation', action='store_true', help='Write detailed Gecko navigation logs for this trial')
parser.add_argument('--own-compositor', action='store_true', help='Run inside the dedicated temporary Tundra compositor, without Phosh services')
parser.add_argument('--diagnostics', action='store_true', help='Open apps and scroll Gaia for the interactive qualification suite')
args = parser.parse_args()
if os.getuid() == 0:
raise RuntimeError('Run as the graphical session user, never root')
drop_capabilities()
trials = str(Path.home()/'vulpes-tundra-tests').encode()+b'/'
for proc in Path('/proc').glob('[0-9]*'):
try:
command = (proc/'cmdline').read_bytes().split(b'\0')
except OSError:
continue
if command and command[0].startswith(trials) and b'/product/engines/' in command[0]:
raise RuntimeError(f'A previous trial engine is still present: PID {proc.name}')
logs = ROOT/'logs'
logs.mkdir(exist_ok=True)
guard = (logs/'session.lock').open('w')
fcntl.flock(guard, fcntl.LOCK_EX | fcntl.LOCK_NB)
with socket.socket() as check:
check.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
check.bind(('127.0.0.1', 8765))
env = {k:v for k,v in os.environ.items() if not k.startswith('MOZ_DISABLE_')}
for key in ('MOZ_HEADLESS', 'MOZ_MARIONETTE', 'B2G_HOMESCREEN'):
env.pop(key, None)
runtime_dir = Path(f'/run/user/{os.getuid()}')
display = 'tundra-0' if args.own_compositor else 'wayland-0'
if not (runtime_dir/display).is_socket():
raise RuntimeError('Existing Wayland session not found')
env.update(XDG_RUNTIME_DIR=str(runtime_dir), WAYLAND_DISPLAY=display,
DBUS_SESSION_BUS_ADDRESS=env.get('DBUS_SESSION_BUS_ADDRESS',f'unix:path={runtime_dir}/bus'),
MOZ_ENABLE_WAYLAND='1', EGL_PLATFORM='wayland', GDK_GL='gles',
VULPES_FULLSCREEN='1', VULPES_NATIVE_TOUCH='1',
VULPES_WORKSPACE_ROOT=str(ROOT))
if args.hybris_egl:
import hashlib
graphics = ROOT/'trial/graphics'
manifest = json.loads((graphics/'manifest.json').read_text())
vendor = Path(manifest['vendorLibrary'])
adapter = graphics/'libEGL_vulpes_hybris.so'
for path, expected in [(vendor,manifest['vendorSha256']), (adapter,manifest['adapterSha256'])]:
if hashlib.sha256(path.read_bytes()).hexdigest() != expected:
raise RuntimeError('EGL adapter/library changed; requalify before use: '+str(path))
descriptor = graphics/'vulpes-egl.json'
descriptor.write_text(json.dumps({'file_format_version':'1.0.0','ICD':{'library_path':str(adapter)}}))
env['__EGL_VENDOR_LIBRARY_FILENAMES'] = str(descriptor)
env['TUNDRA_DIAGNOSTICS'] = '1' if args.diagnostics else '0'
env['TUNDRA_BOOT_ID'] = Path('/proc/sys/kernel/random/boot_id').read_text().strip()
env['TUNDRA_HEADLESS'] = '1' if args.headless else '0'
env['TUNDRA_OWN_COMPOSITOR'] = '1' if args.own_compositor else '0'
if args.trace_navigation:
env.update(MOZ_LOG='sync,timestamp,nsDocShell:5,DocumentChannel:5,ProcessIsolation:5,nsHttp:5',
MOZ_LOG_FILE=str(logs/'navigation.log'))
if args.headless:
env.update(MOZ_HEADLESS='1', VULPES_FULLSCREEN='0')
inhibitor = None
if not args.headless and not args.own_compositor:
from gi.repository import Gio, GLib
bus = Gio.DBusConnection.new_for_address_sync(env['DBUS_SESSION_BUS_ADDRESS'],
Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION,
None, None)
locked = bus.call_sync('org.gnome.ScreenSaver', '/org/gnome/ScreenSaver',
'org.gnome.ScreenSaver', 'GetActive', None, GLib.VariantType.new('(b)'),
Gio.DBusCallFlags.NONE, 5000, None).unpack()[0]
if locked:
raise RuntimeError('Unlock the phone before starting the visible trial')
cookie = bus.call_sync('org.gnome.SessionManager', '/org/gnome/SessionManager',
'org.gnome.SessionManager', 'Inhibit',
GLib.Variant('(susu)', ('org.vulpes.TundraTrial', 0, 'Essai graphique Vulpes', 8)),
GLib.VariantType.new('(u)'), Gio.DBusCallFlags.NONE, 5000, None).unpack()[0]
# The session manager releases this temporary idle inhibitor when our
# bus connection closes, including after a crash. Manual lock still works.
inhibitor = bus
sys.path.insert(0, str(ROOT/'tools'))
spec = importlib.util.spec_from_file_location('host', ROOT/'tools/run-host.py')
host = importlib.util.module_from_spec(spec)
spec.loader.exec_module(host)
lock = json.loads((ROOT/'engine-lock.json').read_text())
runtime = host.prepare(lock, test=True)
import shutil
shutil.copy2(ROOT/'trial/probe.sys.mjs', runtime/'vulpes/tests/DroidianProbe.sys.mjs')
with (runtime/'vulpes.cfg').open('a') as cfg:
cfg.write('''
Services.obs.addObserver({observe(win) {
win.addEventListener('load', () => {
if(win.document.documentElement.getAttribute('windowtype') === 'vulpes:host')
ChromeUtils.importESModule('resource://vulpes/tests/DroidianProbe.sys.mjs').run(win)
.catch(e => dump('TUNDRA_PROBE_ERROR '+e+'\\n'));
}, {once:true});
}}, 'domwindowopened');
''')
env['VULPES_HOST_ROOT'] = str(runtime/'vulpes/host')
profile = ROOT/'profiles/droidian-trial'
profile.mkdir(parents=True, exist_ok=True)
prefs = {'browser.shell.checkDefaultBrowser':False,
'font.default.x-western':'sans-serif',
'dom.w3c_touch_events.legacy_apis.enabled':True,
'font.name.sans-serif.x-western':'Fira Sans',
'browser.dom.window.dump.enabled':True,
'browser.startup.homepage_override.mstone':'ignore',
'browser.aboutwelcome.enabled':False,
'datareporting.policy.dataSubmissionEnabled':False,
'toolkit.telemetry.enabled':False, 'app.update.auto':False,
'marionette.enabled':env.get('TUNDRA_LOCAL_DEBUGGER')=='1',
'marionette.port':2858,'marionette.host':'127.0.0.1',
# Write both states: prefs.js retains values between trials.
'gfx.webrender.software':args.software,
'layers.acceleration.disabled':args.software,
'gfx.webrender.allow-partial-present-buffer-age':False,
'dom.ipc.forkserver.enable':not args.no_forkserver}
if args.software:
prefs.update({'gfx.webrender.software':True, 'layers.acceleration.disabled':True,
'media.ffmpeg.vaapi.enabled':False})
(profile/'user.js').write_text('\n'.join(
f'user_pref({json.dumps(k)}, {json.dumps(v)});' for k,v in prefs.items())+'\n')
(logs/'session.pid').write_text(str(os.getpid())+'\n')
def stop(signum, frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, stop)
processes = []
try:
with (logs/'server.log').open('w') as output:
server = subprocess.Popen([sys.executable, '-u', str(ROOT/'tools/serve-gaia.py')],
stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
processes.append(server)
for _ in range(100):
if server.poll() is not None:
raise RuntimeError('Gaia server exited: see logs/server.log')
try:
with socket.create_connection(('127.0.0.1',8765), timeout=.2):
break
except OSError:
time.sleep(.1)
else:
raise RuntimeError('Gaia server startup timeout')
with (logs/'gecko.log').open('w') as output:
debug_args=['--remote-allow-system-access','-marionette','-new-window','about:blank'] if env.get('TUNDRA_LOCAL_DEBUGGER')=='1' else []
firefox = subprocess.Popen([str(runtime/'firefox'), *debug_args, '-purgecaches', '-no-remote',
'-profile', str(profile), '-chrome', 'chrome://vulpes/content/shell.xhtml'],
env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
processes.append(firefox)
result = firefox.wait()
if result:
raise RuntimeError(f'Gecko exited with {result}: see logs/gecko.log')
finally:
remaining = []
for process in reversed(processes):
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
remaining.append(process.pid)
(logs/'stop.json').write_text(json.dumps({'unreapedProcesses':remaining})+'\n')
(logs/'session.pid').unlink(missing_ok=True)
if inhibitor:
inhibitor.close_sync(None)
if remaining:
raise RuntimeError(f'Kernel has not released trial processes: {remaining}')
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
pass

View file

@ -0,0 +1,8 @@
{
"upstream": "https://github.com/capyloon/manifests",
"manifestCommit": "5f5e4f5348a45c97865b4cfd33b6812bbefa0993",
"resolvedAt": "2026-09-24T07:34:33.525796+00:00",
"projectCount": 393,
"sha256": "1d3961dcc1873c8fec5ecfd516dfb03fa3e8c2d14ca15ca8a5d02946459d8c4c",
"scope": "Capyloon Gonk reference only; not a Vulpes/Tundra product"
}

View file

@ -0,0 +1,416 @@
<?xml version='1.0' encoding='utf-8'?>
<manifest>
<remote fetch="https://github.com/capyloon/" name="capyloon" />
<project groups="capyloon" name="api-daemon" path="gonk-misc/api-daemon" remote="capyloon" revision="258e11dafa1020acd5a92d3ae3563e566b685ef1" />
<project groups="capyloon" name="libsignal-protocol-c" path="gonk-misc/api-daemon/support/libsignal-sys/libsignal-protocol-c" remote="capyloon" revision="3a83a4f4ed2302ff6e68ab569c88793b50c22d28" />
<project groups="capyloon" name="gecko-b2g" path="gecko" remote="capyloon" revision="01c461731c5d86c0e2c8ffef05f55e25092c0858" />
<project groups="capyloon" name="gonk-misc" path="gonk-misc" remote="capyloon" revision="f01816e5027a58581358267c3d8035217d6d3655" />
<project groups="capyloon" path="system/sepolicy" name="platform_system_sepolicy" remote="capyloon" revision="e1b00e3746046edd637bf100e780bb5c876d3f69" />
<project groups="capyloon" name="nutria" path="nutria" remote="capyloon" revision="7cd80945d4b3ebbcd95f22564462111d646044d0" />
<remote fetch="https://github.com/b2gos/" name="b2gos" />
<remote fetch="https://github.com/kaiostech/" name="kaios" />
<default revision="refs/tags/android-10.0.0_r11" remote="aosp" sync-j="4" />
<project groups="kai" name="hidl-gen" path="system/tools/hidl" remote="kaios" revision="d3b9494ecdae46cf73963f4c7d6b2e6b6ad98c6e" />
<project groups="kai" name="gonk-binder" path="gonk-misc/gonk-binder" remote="kaios" revision="61e13e9922b06b9cb15f002631e5675e4da30ad7" />
<project groups="kai" name="fake_test" path="test" remote="kaios" revision="bd7218f376de40f158dac9565a8496b4386dfbf4">
<copyfile dest="frameworks/base/packages/Android.mk" src="vts/tools/build/Android.host_config.mk" />
</project>
<project groups="kai" name="platform_build" path="build/make" remote="b2gos" revision="d650627eaec549932431dc3fba4cbce4c4a7faad">
<copyfile src="core/root.mk" dest="Makefile" />
<linkfile src="CleanSpec.mk" dest="build/CleanSpec.mk" />
<linkfile src="buildspec.mk.default" dest="build/buildspec.mk.default" />
<linkfile src="core" dest="build/core" />
<linkfile src="envsetup.sh" dest="build/envsetup.sh" />
<linkfile src="target" dest="build/target" />
<linkfile src="tools" dest="build/tools" />
</project>
<project groups="kai" name="platform_system_core" path="system/core" remote="b2gos" revision="45feb96d27345be7f626d84e01570d0121376b5d" />
<project groups="kai" name="platform_system_vold" path="system/vold" remote="b2gos" revision="d22e48e662378894de6ffa7a956d2c6776642213" />
<project groups="kai" name="platform_build_soong" path="build/soong" remote="kaios" revision="8a0b9b2fe3399a3b1e44d343e5959b2c74b2e5ff">
<linkfile src="root.bp" dest="Android.bp" />
<linkfile src="bootstrap.bash" dest="bootstrap.bash" />
</project>
<project groups="kai" name="platform_system_connectivity_wificond" path="system/connectivity/wificond" remote="kaios" revision="ba22d5bf7c66d3345b9db1f2fafd9fbda00a71d0" />
<project groups="kai" name="platform_art" path="art" remote="kaios" revision="78fc9b8f863961d9ccebd95f98ca4f04dca95917" />
<project groups="kai" name="platform_external_svox" path="external/svox" remote="kaios" revision="763a576399e4e83140e19b381a39b22d1f5ec066" />
<project groups="kai" name="librecovery" path="librecovery" remote="kaios" revision="e3025e1d1f5569da01cda0b2dd02b4c702e9dffb" />
<remote fetch="https://android.googlesource.com" name="aosp" />
<project path="build/blueprint" name="platform/build/blueprint" groups="pdk,tradefed" revision="d297a0797d1c0955ceb923ca38ce0dfb13236337" />
<project path="build/kati" name="platform/build/kati" groups="pdk,tradefed" revision="e30eb34771b23aafba117cc1a1c69f0d579410b6" />
<project path="bionic" name="platform/bionic" groups="pdk" revision="290c0cb5044b643e5d6cbcb1a5b275541ca3a89e" />
<project path="bootable/recovery" name="platform/bootable/recovery" groups="pdk" revision="5a6cde8b02d860932a7e415990d3ec00539bdd98" />
<project path="development" name="platform/development" groups="developers,pdk-cw-fs,pdk-fs" revision="c561950d8b3f3f8e23030a2b1ff91f6af6c1bc32" />
<project path="device/common" name="device/common" groups="pdk-cw-fs,pdk" revision="d8005f007f3eff52bff2152da9abb8b3fa47415e" />
<project path="device/sample" name="device/sample" groups="pdk" revision="299fe60cb028e05c2b8339e7a75656ca19d2d4f6" />
<project path="external/aac" name="platform/external/aac" groups="pdk" revision="566b6fd0a4116df5e70b3bf833be88aa39542bdb" />
<project path="external/adt-infra" name="platform/external/adt-infra" groups="adt-infra,notdefault,pdk-fs" revision="e9b78ba5bd62a2ce15b72f5ad01fa7eae8d0ed88" />
<project path="external/android-clat" name="platform/external/android-clat" groups="pdk" revision="7a459c9d6b413adb172c079e384b9289178a76d0" />
<project path="external/antlr" name="platform/external/antlr" groups="pdk" revision="9b59a5da94b5c1fe3e6f07a2c252aa211f0a1aa7" />
<project path="external/apache-commons-bcel" name="platform/external/apache-commons-bcel" groups="pdk" revision="5c719840ad597f7504e11b7020bfb6203bbdc960" />
<project path="external/apache-http" name="platform/external/apache-http" groups="pdk" revision="1f8668e30d3c8e617593af776b79186f5b6db724" />
<project path="external/apache-xml" name="platform/external/apache-xml" groups="pdk" revision="f86aef26025c07b3d07f4c56be6f80ae9fc7eb4f" />
<project path="external/archive-patcher" name="platform/external/archive-patcher" groups="pdk" revision="6403002c15c66ae2750009e8ca0e0fe88127cead" />
<project path="external/arm-optimized-routines" name="platform/external/arm-optimized-routines" groups="pdk" revision="2b1324f72857be13c6096a3bfba9e4265d26c8e6" />
<project path="external/avb" name="platform/external/avb" groups="pdk" revision="61b7929b46a59ed155512c960f94be014926fd3b" />
<project path="external/bcc" name="platform/external/bcc" groups="pdk" revision="3c322a224807a4d4b95b709797f0667c3c0df97e" />
<project path="external/boringssl" name="platform/external/boringssl" groups="pdk" revision="7cb5584227c12b41ba11407b98bf896cdbd3d833" />
<project path="external/bouncycastle" name="platform/external/bouncycastle" groups="pdk" revision="85930042a8a67b5dc9dc72e1f23602f237634813" />
<project path="external/brotli" name="platform/external/brotli" groups="pdk" revision="1f51c95f80e50fb8cacbe995ab24ce0702f26147" />
<project path="external/bsdiff" name="platform/external/bsdiff" groups="pdk" revision="58f535dfc203abbfd43c3eb1784cad62ad20dbe9" />
<project path="external/bzip2" name="platform/external/bzip2" groups="pdk" revision="6a47db2a90c40da61e63a361a9226a3a486bd081" />
<project path="external/cblas" name="platform/external/cblas" groups="pdk" revision="b9f9bfc8cde5c44f5b1bde83dc1863dd414c3bd3" />
<project path="external/clang" name="platform/external/clang" groups="pdk" revision="15de91adf9620bdd48369ef8bb90ade214ba187c" />
<project path="external/cldr" name="platform/external/cldr" groups="pdk" revision="6100e79c99eb955e340b965479e4ce1f58ce5f60" />
<project path="external/cn-cbor" name="platform/external/cn-cbor" groups="pdk" revision="0205b470c8abc46559123aa94c72ae183983b8b0" />
<project path="external/compiler-rt" name="platform/external/compiler-rt" groups="pdk" revision="aa9a1d65f4e4a4061b666effbc8b7225c1eab7d4" />
<project path="external/conscrypt" name="platform/external/conscrypt" groups="pdk" revision="4bfb4bfebe61d6b8aeb7a187d9a26950f414bd94" />
<project path="external/cros/system_api" name="platform/external/cros/system_api" groups="pdk" revision="45f79514ee80f9cffbbb24593d418ca56c936912" />
<project path="external/curl" name="platform/external/curl" groups="pdk" revision="5b9754d7cf13bde5554863fdec56a8debdc8100a" />
<project path="external/dagger2" name="platform/external/dagger2" groups="pdk" revision="1dbdd0f245d5adedf26c637a8266f4fd9bd4813c" />
<project path="external/dexmaker" name="platform/external/dexmaker" groups="pdk" revision="a82c36e1eede1ab4f55d0b7e5207a0b7c798ac43" />
<project path="external/dlmalloc" name="platform/external/dlmalloc" groups="pdk" revision="de834e580278908d8de8baca89f44a9e052958d0" />
<project path="external/dng_sdk" name="platform/external/dng_sdk" groups="pdk" revision="f17914d62b7972114fff07dce701f1bedfb7786c" />
<project path="external/dnsmasq" name="platform/external/dnsmasq" groups="pdk" revision="685feb702f11e51103ce77989dfcc10f8b452579" />
<project path="external/dhcpcd-6.8.2" name="platform/external/dhcpcd-6.8.2" groups="pdk" revision="b62522e0ddeb334ecd18c532fd12fc4d3f7d6f1b" />
<project path="external/doclava" name="platform/external/doclava" groups="pdk" revision="78de805d6b1a71e4d079a8f65d872f42b1263a6f" />
<project path="external/drm_hwcomposer" name="platform/external/drm_hwcomposer" groups="drm_hwcomposer,pdk-fs" revision="7003f711357733ab728f17fdaba29bbea8d8c02d" />
<project path="external/droiddriver" name="platform/external/droiddriver" groups="pdk" revision="dcc3607de58ddc2431c14ff860401f6a3226aeed" />
<project path="external/dtc" name="platform/external/dtc" groups="pdk" revision="bb8b81602c1d27e0fda0e5139b502010a624e447" />
<project path="external/dynamic_depth" name="platform/external/dynamic_depth" groups="pdk" revision="b9b6a727d692abb5fb0a13229e3a529b2b724889" />
<project path="external/e2fsprogs" name="platform/external/e2fsprogs" groups="pdk" revision="81cfc58743c02abd772185be8675f94725d98e05" />
<project path="external/eigen" name="platform/external/eigen" groups="pdk" revision="50a475f73bc2eeda76fd972bf71b7acf4dd15cf6" />
<project path="external/elfutils" name="platform/external/elfutils" groups="pdk" revision="26eea58be0a78ea40b0ba18ba0dfd6ba470313b6" />
<project path="external/error_prone" name="platform/external/error_prone" groups="pdk" revision="d46dc622f8239e55801ebc233a08338d08092b7b" />
<project path="external/expat" name="platform/external/expat" groups="pdk" revision="3fa94021293127ffd7694867e64cba719dd8fcf1" />
<project path="external/f2fs-tools" name="platform/external/f2fs-tools" groups="pdk" revision="0b3ed15225135efe7de4c54a8388136dd3b38ac8" />
<project path="external/fdlibm" name="platform/external/fdlibm" groups="pdk" revision="4bf7b1b62df9a2e7eb8ce1de1e8691a67a9afce0" />
<project path="external/fec" name="platform/external/fec" groups="pdk" revision="e4f74218f68b0b32fee602c2cd1f719ab5dc6030" />
<project path="external/flac" name="platform/external/flac" groups="pdk" revision="643d45e16e62e0afc5f984195bc26a26540f7cba" />
<project path="external/flatbuffers" name="platform/external/flatbuffers" groups="pdk" revision="7a2e12488e4ac1a65ba9f27564b0b74569172d91" />
<project path="external/fonttools" name="platform/external/fonttools" groups="pdk" revision="e6cdbdc58040bd40b5ad40d042512c97d75f11b4" />
<project path="external/freetype" name="platform/external/freetype" groups="pdk" revision="93615c094b6b7a87b751916f79fe8849477d7ca8" />
<project path="external/fsck_msdos" name="platform/external/fsck_msdos" groups="pdk" revision="0326a7888e7e0ef3f85945879a346bac5736bad2" />
<project path="external/fsverity-utils" name="platform/external/fsverity-utils" groups="pdk" revision="0409109a29b8149754ba279f609facf86dea54a7" />
<project path="external/gemmlowp" name="platform/external/gemmlowp" groups="pdk" revision="b4cafeb2bfd12c1aba2560c63034377d1a3b1a43" />
<project path="external/gflags" name="platform/external/gflags" groups="pdk" revision="0ade797e0dc90d0db0d3d8746e5d5a431252fe0d" />
<project path="external/giflib" name="platform/external/giflib" groups="pdk,qcom_msm8x26" revision="5deb30d063cde09ec0df0e1d2c245438c6a6ab93" />
<project path="external/golang-protobuf" name="platform/external/golang-protobuf" groups="pdk" revision="e5f96040f99593a411ae059fd3862434ae07e5a6" />
<project path="external/google-benchmark" name="platform/external/google-benchmark" groups="pdk" revision="b3884a1d0147530c6348bda5af10be0a1e83c875" />
<project path="external/google-breakpad" name="platform/external/google-breakpad" groups="pdk-fs" revision="59c6358e410caee0059bcfba752e5c374f7bfa42" />
<project path="external/google-fonts/arbutus-slab" name="platform/external/google-fonts/arbutus-slab" groups="pdk" revision="1b64875b1aa610c1d36f4ff41ad8003cae179e4f" />
<project path="external/google-fonts/arvo" name="platform/external/google-fonts/arvo" groups="pdk" revision="af25f6c486f4ce9acd1619ecc84de8ba0bf1e16a" />
<project path="external/google-fonts/carrois-gothic-sc" name="platform/external/google-fonts/carrois-gothic-sc" groups="pdk" revision="8dbc5d490aaefe688d5ef63d09ab9dfd88412656" />
<project path="external/google-fonts/coming-soon" name="platform/external/google-fonts/coming-soon" groups="pdk" revision="261f1d232c228b76235cbcb1ebf2f0edcf69feae" />
<project path="external/google-fonts/cutive-mono" name="platform/external/google-fonts/cutive-mono" groups="pdk" revision="06cf0826111d0780725dcee3c2ede685677aeaa6" />
<project path="external/google-fonts/dancing-script" name="platform/external/google-fonts/dancing-script" groups="pdk" revision="a626819a74ec282fc6282dfc4c297fa8d5867a60" />
<project path="external/google-fonts/lato" name="platform/external/google-fonts/lato" groups="pdk" revision="ca94ba950b5b3fc19aeed55742ac3dbc94ba6c94" />
<project path="external/google-fonts/source-sans-pro" name="platform/external/google-fonts/source-sans-pro" groups="pdk" revision="3b9ec6478d20b2344f8945dc3c08ae7cd4f76d92" />
<project path="external/google-fonts/rubik" name="platform/external/google-fonts/rubik" groups="pdk" revision="6474d0dfd41de59248b1bbc270860f79e26b4aa4" />
<project path="external/google-fonts/zilla-slab" name="platform/external/google-fonts/zilla-slab" groups="pdk" revision="ed65ba7accc396f5de7947f53b6c4bbf64d5c8fd" />
<project path="external/google-fruit" name="platform/external/google-fruit" groups="pdk" revision="ed15a9646d93f21d88937dd6fc9b9c43f922bb7b" />
<project path="external/googletest" name="platform/external/googletest" groups="pdk" revision="1b1cb4e05a1b279996e5d3ddccc80f3332973ef8" />
<project path="external/gptfdisk" name="platform/external/gptfdisk" groups="pdk" revision="db296b2c241fc8c391788ca94240d256ee0643b0" />
<project path="external/grpc-grpc" name="platform/external/grpc-grpc" groups="pdk,tradefed" revision="59cfc14f331646a1a9a329f7525eb4761b280a14" />
<project path="external/guava" name="platform/external/guava" groups="pdk" revision="b0e6c33ea6f6f2f01b822b2bb9e1be8d78e9e80d" />
<project path="external/hamcrest" name="platform/external/hamcrest" groups="pdk" revision="52cd4d20e5e5f93820fc81a1cdce0617eaaae629" />
<project path="external/harfbuzz_ng" name="platform/external/harfbuzz_ng" groups="pdk,qcom_msm8x26" revision="c3bc8c80b6344456fe86adb378f224bceb5e7eb8" />
<project path="external/hyphenation-patterns" name="platform/external/hyphenation-patterns" groups="pdk" revision="8d31bda2eeaad2153a2452f493076f2b26112893" />
<project path="external/icu" name="platform/external/icu" groups="pdk" revision="66a591103d2e8c62714a2139201f6d29c2dc255b" />
<project path="external/image_io" name="platform/external/image_io" groups="pdk" revision="1c21ce75a5862c157ca30bba4a48522c41880021" />
<project path="external/iproute2" name="platform/external/iproute2" groups="pdk" revision="609f843bac1344e9aa6cb05da1fc576fc35900e6" />
<project path="external/ipsec-tools" name="platform/external/ipsec-tools" groups="pdk" revision="ef134b7fdb24d2f2bc9adb27c18e8f3b1abed658" />
<project path="external/iptables" name="platform/external/iptables" groups="pdk" revision="fde7e54806e247a5aa4a1b32ccf6450d2c4fb9a6" />
<project path="external/iputils" name="platform/external/iputils" groups="pdk" revision="e94b4433bb8a6d54d26f2c38627431f2d552efe6" />
<project path="external/jacoco" name="platform/external/jacoco" groups="pdk" revision="9e2a616d3f830a4bf8c05bd8f1a8ce089957f15a" />
<project path="external/jarjar" name="platform/external/jarjar" groups="pdk" revision="bf247505b132755d118f94753c939164818fdbfd" />
<project path="external/javapoet" name="platform/external/javapoet" groups="pdk" revision="5110124a043357890d3e39b8452b0f0d4f2434d5" />
<project path="external/jemalloc_new" name="platform/external/jemalloc_new" groups="pdk" revision="8129245231d25fdcd829e00fda895d177d552cdf" />
<project path="external/jsilver" name="platform/external/jsilver" groups="pdk" revision="3d1bccd365acd874b88c56796a310f5b248f5d06" />
<project path="external/jsmn" name="platform/external/jsmn" groups="pdk" revision="f1def95191cff7ec43b47f529435c7a5bf8ce797" />
<project path="external/jsoncpp" name="platform/external/jsoncpp" groups="pdk" revision="c79fb39ebc68f67237008caee6044bedbb70b667" />
<project path="external/jsr305" name="platform/external/jsr305" groups="pdk" revision="1f973a62b1b88d88377640af09146e281f702fa3" />
<project path="external/jsr330" name="platform/external/jsr330" groups="pdk" revision="e0211d480f56643be326abe6910662b4c0a0705c" />
<project path="external/junit" name="platform/external/junit" groups="pdk" revision="81a7dfc93ee1c8566774d233086ee9f6a2a3d802" />
<project path="external/junit-params" name="platform/external/junit-params" groups="pdk" revision="738f67d1a51140e5f3b79821e483251c29505875" />
<project path="external/kernel-headers" name="platform/external/kernel-headers" groups="pdk" revision="b7dc99752afda020540c907de151db383071197d" />
<project path="external/kmod" name="platform/external/kmod" groups="pdk" revision="8e6aed6a42d232acfca00924d89c43150f023f77" />
<project path="external/kotlinc" name="platform/external/kotlinc" groups="pdk" revision="41076dadb696f752ca6f6b96fcbcee6145739e32" />
<project path="external/ksoap2" name="platform/external/ksoap2" groups="pdk" revision="27dd0f93eb7f144039d95f2a34182ebb4b8e2588" />
<project path="external/libaom" name="platform/external/libaom" groups="pdk" revision="ae9e769d90104bb1a680601e15c9befdd26e21cf" />
<project path="external/libavc" name="platform/external/libavc" groups="pdk" revision="c0748eed793619fd6478af214b981fc357bb3d9a" />
<project path="external/libbrillo" name="platform/external/libbrillo" groups="pdk" revision="4a5874013883cae8ae808bd7dc87dfd7be141d4b" />
<project path="external/libcap" name="platform/external/libcap" groups="pdk" revision="5028a14854ec5ada942bf461ea320aeb2e201681" />
<project path="external/libcap-ng" name="platform/external/libcap-ng" groups="pdk" revision="86fa2e5819d690eb8bfec18d9ebb470316e9f0c4" />
<project path="external/libchrome" name="platform/external/libchrome" groups="pdk" revision="dd4bfc287142cf9786847bc680dafbc811ea60a9" />
<project path="external/libcxx" name="platform/external/libcxx" groups="pdk" revision="f20058ab7c0709eda6eee44396348ca5e3e982d6" />
<project path="external/libcxxabi" name="platform/external/libcxxabi" groups="pdk" revision="b599c4d59ebf8d589c82a1d5fd98a6c0a955fe7b" />
<project path="external/libdaemon" name="platform/external/libdaemon" groups="pdk" revision="e142788e1d94b0e9b72cc0eea08e93d6d1c91309" />
<project path="external/libdivsufsort" name="platform/external/libdivsufsort" groups="pdk" revision="2141a1de3061f5ca6c6e4e52ea7bec2a4a899a01" />
<project path="external/libdrm" name="platform/external/libdrm" groups="pdk" revision="13a1e448b86ceef18cdb559631634b3547cf1dc5" />
<project path="external/libevent" name="platform/external/libevent" groups="pdk" revision="96db3fd6426b3f087c35b424ce2d3bdd5eb4cb23" />
<project path="external/libexif" name="platform/external/libexif" groups="pdk" revision="ab1ce62b0b0463bdfd9b2a008e2ef3f580aa1bf7" />
<project path="external/libffi" name="platform/external/libffi" groups="pdk" revision="ae37f888918b2b747f41a05e9ef44eef64555ced" />
<project path="external/libgsm" name="platform/external/libgsm" groups="pdk" revision="80a78c87f2b3b610d7c40108dc800283d13588e3" />
<project path="external/libhevc" name="platform/external/libhevc" groups="pdk" revision="9da2cd71f05c30c0a57775c187340f44dca96aa1" />
<project path="external/libjpeg-turbo" name="platform/external/libjpeg-turbo" groups="pdk" revision="0f346c08878097e6170c080b1b11d3596a774ac8" />
<project path="external/libkmsxx" name="platform/external/libkmsxx" groups="pdk" revision="da532047ae149efa759c779ee727a7546d6be1ce" />
<project path="external/libldac" name="platform/external/libldac" groups="pdk" revision="cf9f5e779e51bc08881f9b974c90a386eec49ffe" />
<project path="external/libmpeg2" name="platform/external/libmpeg2" groups="pdk" revision="ae7bd65ea1525f352053265fcbd4dc0b68c5b2a8" />
<project path="external/libmtp" name="platform/external/libmtp" groups="pdk" revision="3df8813cd888e41da669cc973a395e7241770177" />
<project path="external/libnetfilter_conntrack" name="platform/external/libnetfilter_conntrack" groups="pdk" revision="d2e84af93307c60a0c8f08cc94b36c911bd447bd" />
<project path="external/libnfnetlink" name="platform/external/libnfnetlink" groups="pdk" revision="6ab4f09c43cfc4f36249638eca48720b9e762c5e" />
<project path="external/libnl" name="platform/external/libnl" groups="pdk" revision="c188cb50c21960213a754eda442b6964381daf05" />
<project path="external/libogg" name="platform/external/libogg" groups="pdk" revision="86fca8fc4b3c40e258cddfebb1a1f9325ecdab18" />
<project path="external/libopus" name="platform/external/libopus" groups="pdk" revision="f52454f858828fddd6b45b478ed4081dadcc8ac4" />
<project path="external/libpcap" name="platform/external/libpcap" groups="pdk" revision="457d411a864056f09d464cf47fa66b89c46ca728" />
<project path="external/libphonenumber" name="platform/external/libphonenumber" groups="pdk" revision="29c42a22c0600e9fb41d899c5735f92a7f39f148" />
<project path="external/libpng" name="platform/external/libpng" groups="pdk" revision="80c03e1c7bd6d3edebe795375103927bef2b67f3" />
<project path="external/libtextclassifier" name="platform/external/libtextclassifier" groups="pdk" revision="2a637d322b9f086fb80d89d4b02b22a8ed2c5335" />
<project path="external/libunwind" name="platform/external/libunwind" groups="pdk" revision="8ee50f9f4628b49328cf760adfc23981aa1dced0" />
<project path="external/libunwind_llvm" name="platform/external/libunwind_llvm" groups="pdk" revision="c84e52d2279c3ba0017511b64515ea56c18e327d" />
<project path="external/libusb" name="platform/external/libusb" groups="pdk" revision="1dc724d10ebd8c22f748df743f9a0973839e50d4" />
<project path="external/libusb-compat" name="platform/external/libusb-compat" groups="pdk" revision="05b9b5e722ac178e9fdec2f65c8004eb21bc54fe" />
<project path="external/libutf" name="platform/external/libutf" groups="pdk" revision="52b0b2a51391c419abc921a5b3f68a1126ac7809" />
<project path="external/libvpx" name="platform/external/libvpx" groups="pdk" revision="f2a7203e9db7d437eb7f0d53fa078959ff5e9188" />
<project path="external/libvterm" name="platform/external/libvterm" groups="pdk" revision="bcf55745eed74e6e927aac2faceafa6351910e7b" />
<project path="external/libxaac" name="platform/external/libxaac" groups="pdk" revision="bfe7fe1e90011700833497ac0afbe9e4df50dbe8" />
<project path="external/libxcam" name="platform/external/libxcam" groups="pdk" revision="516bcc7ab47d03c1b25c3f81831ddfdb8f63da0c" />
<project path="external/libxkbcommon" name="platform/external/libxkbcommon" groups="pdk" revision="e2df268a8c9b22ec40d08bcc0561ea8d0bb29620" />
<project path="external/libxml2" name="platform/external/libxml2" groups="pdk,libxml2" revision="e6664e3ad58b78015ac6b4b4f4012d044ef76353" />
<project path="external/libyuv" name="platform/external/libyuv" groups="pdk,libyuv" revision="61548af20b13116d7434bfc981af21206bf4b578" />
<project path="external/llvm" name="platform/external/llvm" groups="pdk" revision="b2be197561e9bf4640ef26b09dfa6903eb6f7eaf" />
<project path="external/lua" name="platform/external/lua" groups="pdk" revision="0b7af50e30960f67ace45d1c2efbe7e6903c2f0f" />
<project path="external/ltp" name="platform/external/ltp" groups="vts,pdk" revision="53d738a193c245bb2ead1d041968fcb87c294684" />
<project path="external/lz4" name="platform/external/lz4" groups="pdk" revision="03641fec203ff27510bc528c0abf06d88f28421e" />
<project path="external/lzma" name="platform/external/lzma" groups="pdk" revision="f288e2b376d897e038074d8093b0a363bd690dbc" />
<project path="external/mdnsresponder" name="platform/external/mdnsresponder" groups="pdk" revision="748da835d39cd7516d23e7f534298f4d2c9f7626" />
<project path="external/mesa3d" name="platform/external/mesa3d" groups="pdk-cw-fs,pdk-fs" revision="29cf72879f6553530ddffeb48745331a24d64941" />
<project path="external/minijail" name="platform/external/minijail" groups="pdk" revision="1232d0de14a06b2273b0e2431d96398f511c1344" />
<project path="external/mksh" name="platform/external/mksh" groups="pdk" revision="913d3aefd6305651909e1ee2757ffa5695b2e43e" />
<project path="external/mockito" name="platform/external/mockito" groups="pdk" revision="04539913bb06078951fa0d9b192cb896a006de3c" />
<project path="external/mockwebserver" name="platform/external/mockwebserver" groups="pdk" revision="9a5aa90d0e8d7585625e463240ec3081527003f1" />
<project path="external/modp_b64" name="platform/external/modp_b64" groups="pdk" revision="58d57da79ad86f11c5be09d117846da060aecf67" />
<project path="external/mtpd" name="platform/external/mtpd" groups="pdk" revision="c12dd96f7694e098fbd2fd4e1d0f15869f349d49" />
<project path="external/nanohttpd" name="platform/external/nanohttpd" groups="pdk" revision="66d704343de931004ba23ea5b11119d8b48db843" />
<project path="external/nanopb-c" name="platform/external/nanopb-c" groups="pdk" revision="f0581c36240af1e25a4f7c36dd4c5fd7da6bf85b" />
<project path="external/naver-fonts" name="platform/external/naver-fonts" groups="pdk" revision="f06257d8edec23934a0147a67c0fc37f0ef9e39f" />
<project path="external/neon_2_sse" name="platform/external/neon_2_sse" groups="pdk" revision="7dff91633c4faf01e78427a0d1626853d96289ab" />
<project path="external/newfs_msdos" name="platform/external/newfs_msdos" groups="pdk" revision="10bfab6bf50fdb667aaf49783040a6a168576011" />
<project path="external/nist-sip" name="platform/external/nist-sip" groups="pdk" revision="69c2559a60d9297936969d3d42eaaf96f3f53f63" />
<project path="external/noto-fonts" name="platform/external/noto-fonts" groups="pdk" revision="1fe4f8ac75e1b4fc313ce80e715c333f7a0b645c" />
<project path="external/oauth" name="platform/external/oauth" groups="pdk" revision="51244be8e3d1a230a1fee68dfc0c2b295b04585b" />
<project path="external/objenesis" name="platform/external/objenesis" groups="pdk" revision="16f450956df30c8e6d22d3b56b5c14fba8d77bcf" />
<project path="external/oj-libjdwp" name="platform/external/oj-libjdwp" groups="pdk" revision="d6d36dd34485f6774b54269d7a7838990755de90" />
<project path="external/okhttp" name="platform/external/okhttp" groups="pdk" revision="b8e8451e7df29f0d08bc8e7512266844ba304a84" />
<project path="external/one-true-awk" name="platform/external/one-true-awk" groups="pdk" revision="8b68d9e476b2cb280b55010da762efe11d61c0fe" />
<project path="external/parameter-framework" name="platform/external/parameter-framework" groups="pdk" revision="20467725a2aeb324f03b6bae9b54d54ebbc1c3cb" />
<project path="external/pcre" name="platform/external/pcre" groups="pdk" revision="7bfc4bd443fcfdef4b74adfecd29b060e217af28" />
<project path="external/pdfium" name="platform/external/pdfium" groups="pdk" revision="139f0ef2ee5da8dcb3c0c65151569555f6e801be" />
<project path="external/perfetto" name="platform/external/perfetto" groups="pdk" revision="29e47ffb5bea63835723f6baae86d7bd1ea66c6c" />
<project path="external/piex" name="platform/external/piex" groups="pdk" revision="7df351d7fc9356ff8d244ca830a736936bd7343d" />
<project path="external/ply" name="platform/external/ply" groups="pdk" revision="48dedf06abaeb7604f9dae1fc8055fe8d2a57d94" />
<project path="external/ppp" name="platform/external/ppp" groups="pdk" revision="7aee9a1cc3b2f2aba3e660bce7357620f2bd37f7" />
<project path="external/proguard" name="platform/external/proguard" groups="pdk" revision="cc4ab376521b1cb4b214bc3fcdc58f762fa4f88c" />
<project path="external/protobuf" name="platform/external/protobuf" groups="pdk" revision="72d4b92eaca9390c6f857103c6c8f7b2ee1b6e29" />
<project path="external/puffin" name="platform/external/puffin" groups="pdk" revision="e30f4ba45cd9a1d388fe6a09014fe127699d4943" />
<project path="external/python/cpython2" name="platform/external/python/cpython2" groups="pdk" revision="b141bd8b25320c886ccb01ba765675de397481c8" />
<project path="external/python/six" name="platform/external/python/six" groups="vts,pdk" revision="e154f5d90a0495f7baa8c82b6518bf3184022a65" />
<project path="external/rappor" name="platform/external/rappor" groups="pdk" revision="e7a269ba8a9297e85d76c03c0fade6a6f38cab87" />
<project path="external/robolectric" name="platform/external/robolectric" groups="pdk-cw-fs,pdk-fs" revision="fabd4b89e9a409bd544a3768f7e3261ef268b87a" />
<project path="external/robolectric-shadows" name="platform/external/robolectric-shadows" groups="pdk-cw-fs,pdk-fs" revision="274ccff176f77571e606f1ff37b89ac67fb44a3d" />
<project path="external/roboto-fonts" name="platform/external/roboto-fonts" groups="pdk" revision="897ea064fc5e62b0591c560f1ed19df1fa6f47b4" />
<project path="external/rootdev" name="platform/external/rootdev" groups="pdk" revision="4ad506dc050e5c22a2fd423024381d08e3140564" />
<project path="external/Reactive-Extensions/RxCpp" name="platform/external/Reactive-Extensions/RxCpp" groups="pdk" revision="bae49ad92f3e19eb0905c3558fbbc2a6ac5e0115" />
<project path="external/scrypt" name="platform/external/scrypt" groups="pdk" revision="84d0b6a8d98227990a11005472234176cfb017e0" />
<project path="external/selinux" name="platform/external/selinux" groups="pdk" revision="c4d3d93c335fbcd34b589b008ee27e5a2a590a43" />
<project path="external/setupcompat" name="platform/external/setupcompat" groups="pdk" revision="b79602b871d4e97810551a4e7e6936e5dda9b9c5" />
<project path="external/setupdesign" name="platform/external/setupdesign" groups="pdk" revision="b6abc280aac5d793997180af7285b8a8902a4834" />
<project path="external/sfntly" name="platform/external/sfntly" groups="pdk,qcom_msm8x26" revision="e24107ebaf001b0131ff2b8bb7072f0792ac04f9" />
<project path="external/shaderc/spirv-headers" name="platform/external/shaderc/spirv-headers" groups="pdk" revision="43fb5f2f895a55315738a8b2f15fc4857fa3782a" />
<project path="external/shflags" name="platform/external/shflags" groups="pdk" revision="635033e34d294667e9be19a11d83739a289b0af3" />
<project path="external/skia" name="platform/external/skia" groups="pdk,qcom_msm8x26" revision="e55724765970cd22e74b8cfed61307c01c350e8d" />
<project path="external/skqp" name="platform/external/skqp" groups="cts" clone-depth="1" revision="3c0f7c9dfc37213479ea38ec820bc66ec5aa0307" />
<project path="external/sl4a" name="platform/external/sl4a" groups="pdk" revision="bddf875075fc1e42fb67d3b02195312fbbf45f26" />
<project path="external/snakeyaml" name="platform/external/snakeyaml" groups="pdk" revision="26dc55e990ed5f4ea75f0856d60fef8cf70c718d" />
<project path="external/sonic" name="platform/external/sonic" groups="pdk" revision="0409fded9d89c3ed3fb9f9dc513a9815bbe9a8cd" />
<project path="external/sonivox" name="platform/external/sonivox" groups="pdk" revision="bd3c7d4b27db1b0d1cd0bdfa4f79be1895bfa78e" />
<project path="external/speex" name="platform/external/speex" groups="pdk" revision="d7db6d4c3c7713065ace3fce8385b21d5763830b" />
<project path="external/spirv-llvm" name="platform/external/spirv-llvm" groups="pdk" revision="539253b1bbafdc0d8848fc2577dfd46b736ec6d2" />
<project path="external/sqlite" name="platform/external/sqlite" groups="pdk" revision="15bae3770404feb73f088b83a993ad643d4c7b88" />
<project path="external/squashfs-tools" name="platform/external/squashfs-tools" groups="pdk" revision="22fabea1054acd6f2f1346a78c7343cc3f182c10" />
<project path="external/strace" name="platform/external/strace" groups="pdk" revision="351909a213bcce0bafce7aeeb2d553ca91a53fa4" />
<project path="external/swiftshader" name="platform/external/swiftshader" groups="pdk" revision="9f7bb6637f6b8ffd8f0ae542f9e7481875a8281b" />
<project path="external/syzkaller" name="platform/external/syzkaller" groups="projectarch,pdk" revision="180ae32517ac69d7e67051453ea9a58ad82ba629" />
<project path="external/tagsoup" name="platform/external/tagsoup" groups="pdk" revision="1eb6cdbbcff6c9611e40080231b396a31d709942" />
<project path="external/tcpdump" name="platform/external/tcpdump" groups="pdk" revision="d6daf7b89c3f7f055fb3845e5ddf073ccb30e71c" />
<project path="external/tensorflow" name="platform/external/tensorflow" groups="pdk" revision="d23a53b840a21f84d897df3b4fa8580bc2786675" />
<project path="external/tinyalsa" name="platform/external/tinyalsa" groups="pdk" revision="73a4a7243fcbb6a18f3608e3bfd3ddc3f0d47437" />
<project path="external/tinycompress" name="platform/external/tinycompress" groups="pdk" revision="5a86fd0d7dcaf45a3680f654926fe362acc30e76" />
<project path="external/tinyxml" name="platform/external/tinyxml" groups="pdk" revision="eb1213d6385ab44f325c51ff7dc54b4ad7fafffa" />
<project path="external/tinyxml2" name="platform/external/tinyxml2" groups="pdk" revision="4eb67394a8dc6169d7af7546ce5880b80c544084" />
<project path="external/toolchain-utils" name="platform/external/toolchain-utils" revision="9245087f0b39f4cedce5cb2124b099d4cfb6071c" />
<project path="external/toybox" name="platform/external/toybox" groups="pdk" revision="d362c3db9f259110d485b908cd2874141e736432" />
<project path="external/tremolo" name="platform/external/tremolo" groups="pdk" revision="821836d715f2a34c181ea2de0bd40c0ca3ce8806" />
<project path="external/turbine" name="platform/external/turbine" groups="pdk" revision="def4aeed368a8370e01162723b0a5395158d8a8e" />
<project path="external/u-boot" name="platform/external/u-boot" groups="pdk" revision="358d4cae7b40510c549d3b1873236c46aff314af" />
<project path="external/unicode" name="platform/external/unicode" groups="pdk" revision="857ff4668198496ec81b4922364e1b2cbc42fd37" />
<project path="external/ukey2" name="platform/external/ukey2" groups="pdk" revision="7df34cdaeaff990e8bae1e7f45a96ce14d60a470" />
<project path="external/v4l2_codec2" name="platform/external/v4l2_codec2" groups="pdk" revision="c4c55a5e5722350ed2adb23c4fcba6676e61d23f" />
<project path="external/vboot_reference" name="platform/external/vboot_reference" groups="vboot,pdk-fs" revision="92a9fcce1e664e5f0b60de26f902731b3c22dea0" />
<project path="external/virglrenderer" name="platform/external/virglrenderer" groups="pdk" revision="ac756c01c328b4e7494a2915625af6f33cd03794" />
<project path="external/vixl" name="platform/external/vixl" groups="pdk" revision="0d7e40d00f531cba3eb8d850017491e8ced2134f" />
<project path="external/volley" name="platform/external/volley" groups="pdk" revision="ea01835aa128abf149f05aed0301defc7600eb6f" />
<project path="external/vulkan-headers" name="platform/external/vulkan-headers" groups="pdk" revision="bea897952445381add9b3ef2b2f6406d2163e087" />
<project path="external/vulkan-validation-layers" name="platform/external/vulkan-validation-layers" groups="pdk" revision="24a912c55afeb28b19b19dc01d0606f7feb2d8c4" />
<project path="external/walt" name="platform/external/walt" groups="pdk" revision="bb6a254d9769acc1d022d74977fe5680c2c6218c" />
<project path="external/webp" name="platform/external/webp" groups="pdk,qcom_msm8x26" revision="08b9c0fea91369a3a42aa02ad4db50dbbb3f82b5" />
<project path="external/webrtc" name="platform/external/webrtc" groups="pdk" revision="38af3265029c44a6bb7835b1106bed8e3c0a60ca" />
<project path="external/wpa_supplicant_8" name="platform/external/wpa_supplicant_8" groups="pdk" revision="c839ac147e2f3c10661fb34dc060da898d348c6c" />
<project path="external/wycheproof" name="platform/external/wycheproof" groups="pdk" revision="28c7419226cd249f436f08fdc54605bbdd64dd5e" />
<project path="external/xmp_toolkit" name="platform/external/xmp_toolkit" groups="pdk" revision="c6d08432d09fe5bf4f52065216da37bcc73a958e" />
<project path="external/xz-embedded" name="platform/external/xz-embedded" groups="pdk" revision="91d19f661ea057c8fa97a17e0ef64924f48b81fb" />
<project path="external/xz-java" name="platform/external/xz-java" groups="pdk" revision="8085cbc8a2f3e93188475769550db7e83b09f483" />
<project path="external/yapf" name="platform/external/yapf" groups="vts,projectarch,pdk" revision="b940c5bbd269eacc31d6cdaadafddd12e947aab5" />
<project path="external/zlib" name="platform/external/zlib" groups="pdk" revision="73968beb892091e38d611e4ca559b8580226c86b" />
<project path="external/zopfli" name="platform/external/zopfli" groups="pdk" revision="59ddf1b07e1a6479463ee39af6bb7bc0613af857" />
<project path="external/zxing" name="platform/external/zxing" groups="pdk" revision="518f17e6727ca8f395175c3f8833d303d6cdd877" />
<project path="frameworks/av" name="platform/frameworks/av" groups="pdk" revision="0f111c8099df301d6471c3190f872f517353ebbc" />
<project path="frameworks/base" name="platform/frameworks/base" groups="pdk-cw-fs,pdk-fs" revision="37a24f52e6be75e7f7db461e3bee465152318c99" />
<project path="frameworks/compile/libbcc" name="platform/frameworks/compile/libbcc" groups="pdk" revision="fe79261aabe2e8671d6a3d12f4571e0d007a6cfb" />
<project path="frameworks/compile/mclinker" name="platform/frameworks/compile/mclinker" groups="pdk" revision="536e85d253acf6ed4996d2f24ecbea0ba69378ea" />
<project path="frameworks/compile/slang" name="platform/frameworks/compile/slang" groups="pdk" revision="8cf36728dde3724a8a954c6b1ce3c77d8d41e67f" />
<project path="frameworks/ex" name="platform/frameworks/ex" groups="pdk-cw-fs,pdk-fs" revision="6a2716ca27f6a5e29f92e67734e3d3b77123af05" />
<project path="frameworks/hardware/interfaces" name="platform/frameworks/hardware/interfaces" groups="pdk" revision="6eb8ef22f073bff1357778ad690d182fb3d32d75" />
<project path="frameworks/layoutlib" name="platform/frameworks/layoutlib" groups="pdk-cw-fs,pdk-fs" revision="ae2275abf39f8eea243ca2535f98dde3efaf670a" />
<project path="frameworks/minikin" name="platform/frameworks/minikin" groups="pdk-cw-fs,pdk-fs" revision="1ec0bed41d0455a688cdebb77cd2f2c3bab6381a" />
<project path="frameworks/ml" name="platform/frameworks/ml" groups="pdk" revision="e53b7764f026d1c129493f0a8bd34b5b6766c355" />
<project path="frameworks/multidex" name="platform/frameworks/multidex" groups="pdk-cw-fs,pdk-fs" revision="b9aef4192f9584a1e708df6e516fd10febaeb1f5" />
<project path="frameworks/native" name="platform/frameworks/native" groups="pdk" revision="2173449d9dccb15b87c9df5d373dc32e1dcabed6" />
<project path="frameworks/opt/net/ike" name="platform/frameworks/opt/net/ike" groups="pdk" revision="071fc9769f4164753e2fb3b762eba2b139e6fbee" />
<project path="frameworks/opt/net/ims" name="platform/frameworks/opt/net/ims" groups="frameworks_ims,pdk-cw-fs,pdk-fs" revision="b00428b7e9f6cfa49afb16b89063ffc5254fd639" />
<project path="frameworks/opt/net/voip" name="platform/frameworks/opt/net/voip" groups="pdk-cw-fs,pdk-fs" revision="38f3a8779e12277c91c91a00ceb13234870f8af1" />
<project path="frameworks/opt/net/wifi" name="platform/frameworks/opt/net/wifi" groups="pdk" revision="6c3ff83eaa491a9cf54140d718bc114efd45e19f" />
<project path="frameworks/opt/telephony" name="platform/frameworks/opt/telephony" groups="pdk" revision="dd5c3144a3535391b94b4fb220a9ac857ed5e70b" />
<project path="frameworks/rs" name="platform/frameworks/rs" groups="pdk" revision="c950db3b8c410ce7eaed0dcb3afd873241fc8b81" />
<project path="frameworks/wilhelm" name="platform/frameworks/wilhelm" groups="pdk-cw-fs,pdk-fs" revision="e951a49f16c96f6459a1a831dde175b63b1f47ed" />
<project path="hardware/interfaces" name="platform/hardware/interfaces" groups="pdk" revision="18513a12413a5f3788fcf8782c84439203bb1161" />
<project path="hardware/libhardware" name="platform/hardware/libhardware" groups="pdk" revision="9c3fea3ac451821a47437eb53790d0297143c45a" />
<project path="hardware/libhardware_legacy" name="platform/hardware/libhardware_legacy" groups="pdk" revision="be5a9e91beb1f721e5fd8a588ce9cfaee48e617e" />
<project path="hardware/ril" name="platform/hardware/ril" groups="pdk" revision="61502f1e7c7e0d0fcce8f3b37510ffe8f53677c2" />
<project path="kernel/build" name="kernel/build" revision="300f6a933c11aca45a6b48f7ee4306bf2506a114" />
<project path="kernel/configs" name="kernel/configs" groups="vts,pdk" revision="30112286fa61c966be8fcfe1e6c949a61e214131" />
<project path="kernel/tests" name="kernel/tests" groups="vts,pdk" revision="bfef3bb78b23cb3f3f12a6880ecafd5def3b66a5" />
<project path="libcore" name="platform/libcore" groups="pdk" revision="2b72e0b8486db652721095e55f1f068b41989ce0" />
<project path="libnativehelper" name="platform/libnativehelper" groups="pdk" revision="2f4c4c1bc36811b007b926ade8a8f3d2beb75dc0" />
<project path="packages/services/Telephony" name="platform/packages/services/Telephony" groups="pdk-cw-fs,pdk-fs" revision="843c0ce58eec1199b5dcf3e764ac7c6bee6edbdd" />
<project path="pdk" name="platform/pdk" groups="pdk" revision="9f2c75b72f2ad4b636b0158bd6b33b33f6bb0bc9" />
<project path="prebuilts/build-tools" name="platform/prebuilts/build-tools" groups="pdk" clone-depth="1" revision="241060b322d60c3deb447e3fe63be9d479237381" />
<project path="prebuilts/clang-tools" name="platform/prebuilts/clang-tools" groups="pdk" clone-depth="1" revision="7b4b7a026543e0a0c60f9a097311b015f7bf3a5b" />
<project path="prebuilts/clang/host/darwin-x86" name="platform/prebuilts/clang/host/darwin-x86" groups="pdk,darwin" clone-depth="1" revision="1129d053b9852398a36c90f937060c6798b1778c" />
<project path="prebuilts/clang/host/linux-x86" name="platform/prebuilts/clang/host/linux-x86" groups="pdk" clone-depth="1" revision="3f889f9c9d42bae85728fa89ba5f92a01704fd83" />
<project path="prebuilts/cmake/linux-x86" name="platform/prebuilts/cmake/linux-x86" groups="pdk" clone-depth="1" revision="5c9b19360d246af47c29deecc1899f564d602d0b" />
<project path="prebuilts/gcc/darwin-x86/aarch64/aarch64-linux-android-4.9" name="platform/prebuilts/gcc/darwin-x86/aarch64/aarch64-linux-android-4.9" groups="pdk,darwin,arm" clone-depth="1" revision="02d0f9aac2ee958bb505d10f5ee9034779094e43" />
<project path="prebuilts/gcc/darwin-x86/arm/arm-linux-androideabi-4.9" name="platform/prebuilts/gcc/darwin-x86/arm/arm-linux-androideabi-4.9" groups="pdk,darwin,arm" clone-depth="1" revision="9aa1ef21f0105883a7ec90c19a1e2f03b4dce937" />
<project path="prebuilts/gcc/darwin-x86/host/i686-apple-darwin-4.2.1" name="platform/prebuilts/gcc/darwin-x86/host/i686-apple-darwin-4.2.1" groups="pdk,darwin" clone-depth="1" revision="ec5aa66aaa4964c27564d0ec84dc1f18a2d72b7e" />
<project path="prebuilts/gcc/darwin-x86/x86/x86_64-linux-android-4.9" name="platform/prebuilts/gcc/darwin-x86/x86/x86_64-linux-android-4.9" groups="pdk,darwin,x86" clone-depth="1" revision="249c54de5e87e3683fc162e610184b60dd9341b7" />
<project path="prebuilts/gcc/linux-x86/aarch64/aarch64-linux-android-4.9" name="platform/prebuilts/gcc/linux-x86/aarch64/aarch64-linux-android-4.9" groups="pdk,linux,arm" clone-depth="1" revision="84fb09fafc92a3d9b4d160f049d46c3c784cc941" />
<project path="prebuilts/gcc/linux-x86/arm/arm-linux-androideabi-4.9" name="platform/prebuilts/gcc/linux-x86/arm/arm-linux-androideabi-4.9" groups="pdk,linux,arm" clone-depth="1" revision="5a8beef7b1aa2c8ca0dfe4a00358559d12dfa3b6" />
<project path="prebuilts/gcc/linux-x86/host/x86_64-linux-glibc2.17-4.8" name="platform/prebuilts/gcc/linux-x86/host/x86_64-linux-glibc2.17-4.8" groups="pdk,linux" clone-depth="1" revision="c51fde9ed4d420d97ad95626f636448fff5d97c2" />
<project path="prebuilts/gcc/linux-x86/host/x86_64-w64-mingw32-4.8" name="platform/prebuilts/gcc/linux-x86/host/x86_64-w64-mingw32-4.8" groups="pdk-fs" clone-depth="1" revision="02264a9f9b67bd33afe94a02692d3492c7df4bdc" />
<project path="prebuilts/gcc/linux-x86/x86/x86_64-linux-android-4.9" name="platform/prebuilts/gcc/linux-x86/x86/x86_64-linux-android-4.9" groups="pdk,linux,x86" clone-depth="1" revision="e4a6d249d018bf4fa86df49bd15ecd4145862d56" />
<project path="prebuilts/gdb/darwin-x86" name="platform/prebuilts/gdb/darwin-x86" groups="darwin,pdk" clone-depth="1" revision="0a8c90248264a8b26970b4473770bcc3df8515fe" />
<project path="prebuilts/gdb/linux-x86" name="platform/prebuilts/gdb/linux-x86" groups="linux,pdk" clone-depth="1" revision="4adfde8bc82dd39f59e0445588c3e599ada477d2" />
<project path="prebuilts/go/darwin-x86" name="platform/prebuilts/go/darwin-x86" groups="darwin,pdk,tradefed" clone-depth="1" revision="8b7f27557ed8ddacb0a6124dabb0c0ff82f63984" />
<project path="prebuilts/go/linux-x86" name="platform/prebuilts/go/linux-x86" groups="linux,pdk,tradefed" clone-depth="1" revision="5b0834493ee09028bd77b2e08d0723b277470272" />
<project path="prebuilts/gradle-plugin" name="platform/prebuilts/gradle-plugin" groups="pdk,pdk-cw-fs,pdk-fs" clone-depth="1" revision="e051a587f5856ecd3c9f621890fa9380711d719e" />
<project path="prebuilts/jdk/jdk8" name="platform/prebuilts/jdk/jdk8" groups="pdk" clone-depth="1" revision="06a6dface3299f99fb7ed2ad41045d6314925ce4" />
<project path="prebuilts/jdk/jdk9" name="platform/prebuilts/jdk/jdk9" groups="pdk" clone-depth="1" revision="9ed12804ecf554245a7b86661f1d3c6ea47d4b10" />
<project path="prebuilts/manifest-merger" name="platform/prebuilts/manifest-merger" groups="pdk" clone-depth="1" revision="f43915ba129c29516a98ddbfc88bc3942aa68d5d" />
<project path="prebuilts/maven_repo/android" name="platform/prebuilts/maven_repo/android" groups="pdk-cw-fs,pdk-fs" clone-depth="1" revision="5003228f9448e1fc11c4f322555673b8552ccb66" />
<project path="prebuilts/maven_repo/bumptech" name="platform/prebuilts/maven_repo/bumptech" groups="pdk-cw-fs,pdk-fs" clone-depth="1" revision="8198e78965e06de4f808b8e81b6685b8193fa124" />
<project path="prebuilts/misc" name="platform/prebuilts/misc" groups="pdk" clone-depth="1" revision="50dcafd0b789e6afbb8d173b397a5276e1389e0f" />
<project path="prebuilts/ndk" name="platform/prebuilts/ndk" groups="pdk" clone-depth="1" revision="5162519ad277619b086b2e3311238444a5632faa" />
<project path="prebuilts/ktlint" name="platform/prebuilts/ktlint" groups="pdk" clone-depth="1" revision="9e2e8670be6f19f3c77fef568c10ecb091e59196" />
<project path="prebuilts/python/darwin-x86/2.7.5" name="platform/prebuilts/python/darwin-x86/2.7.5" groups="darwin,pdk,pdk-cw-fs,pdk-fs" clone-depth="1" revision="0c5958b1636c47ed7c284f859c8e805fd06a0e63" />
<project path="prebuilts/python/linux-x86/2.7.5" name="platform/prebuilts/python/linux-x86/2.7.5" groups="linux,pdk,pdk-cw-fs,pdk-fs" clone-depth="1" revision="742a8ba7b9eb306622260036b5b47c02db3ec783" />
<project path="prebuilts/qemu-kernel" name="platform/prebuilts/qemu-kernel" groups="pdk" clone-depth="1" revision="13e091470681b13eea293ebb6cf7208c07602ec1" />
<project path="prebuilts/r8" name="platform/prebuilts/r8" groups="pdk" clone-depth="1" revision="ccd6802cc74eea300fb04c6e06c79b375ff925f3" />
<project path="prebuilts/sdk" name="platform/prebuilts/sdk" groups="pdk" clone-depth="1" revision="b2c0c38c937a86aee8f4f93c9708856ee6cf291d" />
<project path="prebuilts/tools" name="platform/prebuilts/tools" groups="pdk,tools" clone-depth="1" revision="3bfdb31b0d5910b6e036c623437b6c5bc8a703b2" />
<project path="sdk" name="platform/sdk" groups="pdk-cw-fs,pdk-fs" revision="0f698222ca9adfa15a5a6013c941a5918bd24566">
<linkfile src="current/androidx-README.md" dest="frameworks/support/README.md" />
</project>
<project path="system/apex" name="platform/system/apex" groups="pdk" revision="42f74c6d7844894af351896e3e9669c8124c94f7" />
<project path="system/ashmemd" name="platform/system/ashmemd" groups="pdk" revision="7761e34bbb8d7956341c67b5e5c7677b6e7ad35c" />
<project path="system/bt" name="platform/system/bt" groups="pdk" revision="ae35d7765dc6f30ae3cca8a1af10a90ca380f7fd" />
<project path="system/bpf" name="platform/system/bpf" groups="pdk" revision="5f215272c82e24d0416911a5e2a53fdcbca5b473" />
<project path="system/bpfprogs" name="platform/system/bpfprogs" groups="pdk" revision="7455c3af5b3184bd900f92fd7feb2232d60574f4" />
<project path="system/ca-certificates" name="platform/system/ca-certificates" groups="pdk" revision="ea03514ced2302d45c35b9ea6553813346affa12" />
<project path="system/chre" name="platform/system/chre" groups="pdk" revision="43cf8774c269a09c765a233cfffde8b8875a3128" />
<project path="system/connectivity/wifilogd" name="platform/system/connectivity/wifilogd" groups="pdk" revision="c4ca16abfdba4e531affd21ea78ab25242231ea2" />
<project path="system/extras" name="platform/system/extras" groups="pdk" revision="1be00e563e8d4e65f94dc85ed68b893010d2070d" />
<project path="system/gatekeeper" name="platform/system/gatekeeper" groups="pdk" revision="b67d133b83c830b5341aaaaae0cb0cb1a45bb415" />
<project path="system/gsid" name="platform/system/gsid" groups="pdk" revision="a42c64f8fc52a3c6d536a1d32d8683f9c7f080ca" />
<project path="system/hardware/interfaces" name="platform/system/hardware/interfaces" groups="pdk" revision="2ff2792a0c5cd668d331960cdc56ddeb3e23d217" />
<project path="system/hwservicemanager" name="platform/system/hwservicemanager" groups="pdk" revision="2f6fdb3bd53ee11afc79d2d7bf59c03d40461f71" />
<project path="system/iorap" name="platform/system/iorap" groups="pdk" revision="b5c8630b7d60cf8c2fd0ee43400d7ee400bd3c2f" />
<project path="system/keymaster" name="platform/system/keymaster" groups="pdk" revision="cd1a0026cb360446a50b0f49373f19a154e02973" />
<project path="system/libfmq" name="platform/system/libfmq" groups="pdk" revision="922a25e2de65dd263810936d86a867574b1d8af8" />
<project path="system/libhidl" name="platform/system/libhidl" groups="pdk" revision="0a5a8e42404174e8e183957c4a51c4e611ad851d" />
<project path="system/libhwbinder" name="platform/system/libhwbinder" groups="pdk" revision="e40fe7f0e987445f23fc7b2aa7769164030b03fc" />
<project path="system/libsysprop" name="platform/system/libsysprop" groups="pdk" revision="36e3f041c70980ff9781b730b5afa93a992e39c2" />
<project path="system/libufdt" name="platform/system/libufdt" groups="pdk" revision="6ab7b7b15fe339e7219f09ab31cf59686997a223" />
<project path="system/libvintf" name="platform/system/libvintf" groups="pdk" revision="b97e5e203e886abdad78f6589943262a3a4aa94a" />
<project path="system/linkerconfig" name="platform/system/linkerconfig" groups="pdk" revision="89ebe3f44e61f17c0c93c0b735f58005a224e900" />
<project path="system/media" name="platform/system/media" groups="pdk" revision="fdc88efc259a7f702c93ecf5afbf0d2453fc0ac1" />
<project path="system/netd" name="platform/system/netd" groups="pdk" revision="e27bf9edf55a77172ab26b2b494cbdf19b545268" />
<project path="system/nfc" name="platform/system/nfc" groups="pdk" revision="0cc1b15ded971bbeb5d00d719d10026af957aaba" />
<project path="system/nvram" name="platform/system/nvram" groups="pdk" revision="c2d6a33bd00449e9e6e2441f3979a799a3428653" />
<project path="system/security" name="platform/system/security" groups="pdk" revision="267635f625ce534473b6f2a869ba680e8c94dbf1" />
<project path="system/server_configurable_flags" name="platform/system/server_configurable_flags" groups="pdk" revision="72f96229b3a67531e79455d52225a4d0702285bb" />
<project path="system/testing/gtest_extras" name="platform/system/testing/gtest_extras" groups="pdk" revision="1c6b32f38fa024e9aeefa367f576cf147000ef25" />
<project path="system/timezone" name="platform/system/timezone" groups="pdk" revision="c199aba418f6b772ba8c254dd1c88e459a0c117b" />
<project path="system/tools/aidl" name="platform/system/tools/aidl" groups="pdk" revision="3a243f1054388bcac4734182a1171f12fc3f7ed2" />
<project path="system/tools/sysprop" name="platform/system/tools/sysprop" groups="pdk" revision="fd51c532a92604c958271557bf1556b8d9b449e2" />
<project path="system/tools/xsdc" name="platform/system/tools/xsdc" groups="pdk" revision="cd96be7d64a0a9ddf51076f30b9402d7d927669e" />
<project path="system/update_engine" name="platform/system/update_engine" groups="pdk" revision="df7c89d6216acabe59ca511514860397477eb70b" />
<project path="system/ucontainer" name="platform/system/ucontainer" groups="pdk" revision="19cc4fab68be7fa042f016dee333db1979a7937b" />
<project path="tools/acloud" name="platform/tools/acloud" groups="tools,vts,projectarch,pdk,tradefed" revision="a5a04590baca2daf95ba38b054af6d19331eed30" />
<project path="tools/adt/idea" name="platform/tools/adt/idea" groups="notdefault,tools" revision="e080adc036a82ea92259c6431e432bdcf464333f" />
<project path="tools/apksig" name="platform/tools/apksig" groups="pdk,tradefed" revision="e1bd6e19a027f460fa8fcc5fbaf2cddfafca8a79" />
<project path="tools/apkzlib" name="platform/tools/apkzlib" groups="pdk,tradefed" revision="734b00a6c447ba82619240d3aba0f21c79b7dd61" />
<project path="tools/base" name="platform/tools/base" groups="notdefault,tools" revision="908b391a9c006af569dfaff08b37f8fdd6c4da89" />
<project path="tools/build" name="platform/tools/build" groups="notdefault,tools" revision="69c4b95102b4b9862bfba68b3eaf5b7537a705ee" />
<project path="tools/currysrc" name="platform/tools/currysrc" groups="pdk" revision="c0fdf3f47111729a0512261f7a46ab1c18d24481" />
<project path="tools/dexter" name="platform/tools/dexter" groups="tools,pdk-fs" revision="20b020455ddfeaf4c907ccf50577145949b277f3" />
<project path="tools/external_updater" name="platform/tools/external_updater" groups="tools" revision="4307e0cf49552229a0468c0618f8cc503822ccde" />
<project path="tools/external/fat32lib" name="platform/tools/external/fat32lib" groups="tools" revision="457ff191c0f801ea47af21445aab1d372619614c" />
<project path="tools/external/gradle" name="platform/tools/external/gradle" groups="tools" clone-depth="1" revision="720597de3e293b5856f0cb304dd75f3d350eb5d1" />
<project path="tools/idea" name="platform/tools/idea" groups="notdefault,tools" revision="9b5d02ac8c92b1e71523cc15cb3d168d57fbd898" />
<project path="tools/loganalysis" name="platform/tools/loganalysis" groups="nopresubmit,pdk,tradefed" revision="39b49e2fd9051045f6e2edde9c6b43813e50a53f" />
<project path="tools/metalava" name="platform/tools/metalava" groups="pdk,tools" revision="5f45ec4987c8cdf2462a9211629bcfe70cb7aeee" />
<project path="tools/motodev" name="platform/tools/motodev" groups="notdefault,motodev" revision="69989786cefbde82527960a1e100ec9afba46a98" />
<project path="tools/ndkports" name="platform/tools/ndkports" groups="pdk" revision="74fefbc2160250129cbd157272fcfb3c9ac20f18" />
<project path="tools/repohooks" name="platform/tools/repohooks" groups="adt-infra,cts,developers,motodev,pdk,tools,tradefed" revision="ca24b4f96bc44bc52ec219653e6dbe5197cbe396" />
<project path="tools/security" name="platform/tools/security" groups="pdk,tools" revision="c5257c3eb92ec9a03dfb8c2fd34e2f45e247f43a" />
<project path="tools/studio/cloud" name="platform/tools/studio/cloud" groups="notdefault,tools" revision="58f06e77e051fff3903adabca7acdaa9dd12ec2d" />
<project path="tools/swt" name="platform/tools/swt" groups="notdefault,tools" revision="8996e71047a2bd11efee46ef14e02435ab5fa07a" />
<project path="tools/tradefederation/core" name="platform/tools/tradefederation" groups="pdk,tradefed" revision="1d8b34223fcc254544e07cd5cd1885a04d03b25e" />
<project path="tools/tradefederation/contrib" name="platform/tools/tradefederation/contrib" groups="pdk,tradefed" revision="0d1a0a46cea5b86be8e58c2add40a992abff0c74" />
<project groups="capyloon" name="beetle" path="ipfsd" remote="capyloon" revision="5008bcf0241b9995804d96e1f24fe6acecc0808b" />
<project path="device/google/bonito" name="device_google_bonito" groups="pdk" remote="b2gos" revision="bfb9d4c3d16cbc0d73eae7f8ea9bf8cf9069c82e" />
<project path="device/google/bonito-kernel" name="device/google/bonito-kernel" groups="pdk" revision="68a5146ea7cb597434497af093ee29ff11089f53" />
<project path="device/google/bonito-sepolicy" name="device/google/bonito-sepolicy" groups="pdk" revision="b98e97de208e18c4440781ab078163a828207eff" />
<project path="hardware/google/av" name="platform/hardware/google/av" groups="pdk" revision="83a1e49e670cc98afd6a7270b0184aa728566c8b" />
<project path="hardware/google/interfaces" name="platform/hardware/google/interfaces" groups="pdk" revision="60e2613ee962f913c0bd144b8bdd9052fe15fcbb" />
<project path="hardware/google/pixel" name="platform/hardware/google/pixel" groups="pdk" revision="c5c696d07a970118c913055ab27437a216e8b8a3" />
<project path="hardware/qcom/sdm845/bt" name="platform/hardware/qcom/sdm845/bt" groups="pdk" revision="7c5a615ed06887fcab892c8ed158d9fbf7843db1" />
<project path="hardware/qcom/sdm845/data/ipacfg-mgr" name="platform/hardware/qcom/sdm845/data/ipacfg-mgr" groups="pdk" revision="d76f8a38cd93264158a1615b25d8614d67f0d115" />
<project path="hardware/qcom/sdm845/gps" name="platform/hardware/qcom/sdm845/gps" groups="pdk" revision="778a600c93a2679360c3285135b03a59c7a17bec" />
<project path="hardware/qcom/sdm845/media" name="platform/hardware/qcom/sdm845/media" groups="pdk" revision="64586c56cd43c6fb39d344927f6f1404b340310c" />
<project path="hardware/qcom/sdm845/thermal" name="platform/hardware/qcom/sdm845/thermal" groups="pdk" revision="0f7571141e0cd51bff8ec865f232921a17116501" />
<project path="hardware/qcom/sdm845/vr" name="platform/hardware/qcom/sdm845/vr" groups="pdk" revision="a61f7f714493cce7ab34a974244f2c2c1a45eea4" />
<project path="hardware/qcom/bootctrl" name="platform/hardware/qcom/bootctrl" groups="pdk" revision="92ea1653a355345dfa9e7c5a33ebe1a1665241e5" />
<project path="external/nos/host/android" name="platform/external/nos/host/android" groups="pdk" revision="e8f1eb38f9e0ba637a368cb82dbcf098690c251f" />
<project path="external/nos/host/generic" name="platform/external/nos/host/generic" groups="pdk" revision="ea05dc1137350c4e2d39cc8344cffbdb3232df39" />
</manifest>

View file

@ -0,0 +1,9 @@
{
"repo": "https://github.com/capyloon/gecko-b2g.git",
"commit": "01c461731c5d86c0e2c8ffef05f55e25092c0858",
"file": "config/milestone.txt",
"url": "https://raw.githubusercontent.com/capyloon/gecko-b2g/01c461731c5d86c0e2c8ffef05f55e25092c0858/config/milestone.txt",
"sha256": "12380d17928a63923ce07d37a94720fd54cf8132c6337d77395ad9ee46755585",
"milestone": "123.0",
"role": "reference only; not the current Vulpes engine"
}

View file

@ -0,0 +1,69 @@
{
"schema": 1,
"resolved": "2026-09-24",
"sources": [
{
"name": "capyloon-b2g",
"url": "https://github.com/capyloon/B2G.git",
"ref": "refs/heads/master",
"commit": "4a0244b8f75811dab05bc4269d17ec2c71e60a94",
"role": "build-reference"
},
{
"name": "capyloon-manifests",
"url": "https://github.com/capyloon/manifests.git",
"ref": "refs/heads/master",
"commit": "5f5e4f5348a45c97865b4cfd33b6812bbefa0993",
"role": "manifest-reference"
},
{
"name": "capyloon-gonk-misc",
"url": "https://github.com/capyloon/gonk-misc.git",
"ref": "refs/heads/main",
"commit": "f01816e5027a58581358267c3d8035217d6d3655",
"role": "system-reference"
},
{
"name": "capyloon-gecko",
"url": "https://github.com/capyloon/gecko-b2g.git",
"ref": "refs/heads/capyloon",
"commit": "01c461731c5d86c0e2c8ffef05f55e25092c0858",
"role": "engine-reference-only"
},
{
"name": "b2gos-sargo",
"url": "https://github.com/b2gos/device_google_bonito.git",
"ref": "refs/heads/gonk_android-10.0.0_r11",
"commit": "bfb9d4c3d16cbc0d73eae7f8ea9bf8cf9069c82e",
"role": "android10-device-reference"
},
{
"name": "ubports-sargo",
"url": "https://gitlab.com/ubports/porting/reference-device-ports/android9/google-pixel-3a/android_device_google_bonito.git",
"ref": "refs/heads/halium-9.0",
"commit": "ef01ef159a4ab1785dd300261d37615bdc732883",
"role": "android9-device-reference"
},
{
"name": "ubports-kernel",
"url": "https://gitlab.com/ubports/porting/reference-device-ports/android9/google-pixel-3a/android_kernel_google_bonito.git",
"ref": "refs/heads/halium-9.0",
"commit": "c77798a65bd637b533634dd2776c25561fa6e24e",
"role": "android9-kernel-source"
},
{
"name": "aosp-mkbootimg",
"url": "https://android.googlesource.com/platform/system/tools/mkbootimg",
"ref": "refs/heads/main",
"commit": "d2bb0af5ba6d3198a3e99529c97eda1be0b5a093",
"role": "boot-packager"
},
{
"name": "aosp-sargo-kernel",
"url": "https://android.googlesource.com/device/google/bonito-kernel",
"ref": "refs/tags/android-9.0.0_r47",
"commit": "d7b44a0be4a684a9e42e69298ee7d9695d1cc035",
"role": "android9-kernel-prebuilt"
}
]
}

View file

@ -0,0 +1,6 @@
{
"url": "https://archlinux.mailtunnel.eu/extra/os/x86_64/weston-15.0.1-3-x86_64.pkg.tar.zst",
"sha256": "92fe0bfe2cf1ae02105b0e6573f6695c6bbd4637c9a6b20299a6fea8c3d23b4d",
"signature": "https://archlinux.mailtunnel.eu/extra/os/x86_64/weston-15.0.1-3-x86_64.pkg.tar.zst.sig",
"signatureVerifiedWith": "local Arch package signing keyring"
}

156
tundra/runtime/buttons.py Normal file
View file

@ -0,0 +1,156 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Sargo key events and fixed backlight/volume controls; no general command API."""
import json
import os
from pathlib import Path
import selectors
import socket
import struct
import subprocess
import sys
import time
import uuid
SOCKET = Path('/run/tundra-buttons/control.sock')
EVENTS = SOCKET.with_name('events.json')
BACKLIGHT = Path('/sys/class/backlight/panel0-backlight')
KEYS = {116: 'sleep', 115: 'volume-up', 114: 'volume-down'}
INPUT = struct.Struct('@llHHi')
def key_event(kind, code, value):
if kind != 1 or code not in KEYS or value not in (0, 1):
return None
return KEYS[code] + '-button-' + ('press' if value else 'release')
VIBRATOR = Path('/sys/class/leds/vibrator')
# This kernel routes DRV2624 start/stop through LED brightness. The legacy
# activate/duration attributes do not reliably stop the physical motor.
class Vibration:
def __init__(self):
self.steps = []
self.deadline = None
def start(self, pattern):
if (not isinstance(pattern, list) or len(pattern) > 32 or
any(type(n) is not int or not 0 <= n <= 10000 for n in pattern) or
sum(pattern) > 10000):
raise ValueError('INVALID_VIBRATION')
self.steps = list(enumerate(pattern))
self.deadline = None
(VIBRATOR/'brightness').write_text('0')
self.advance(time.monotonic())
def advance(self, now):
if self.deadline is not None and now < self.deadline:
return
self.deadline = None
while self.steps:
index, duration = self.steps.pop(0)
if not duration:
continue
if index % 2 == 0:
(VIBRATOR/'brightness').write_text('255')
else:
(VIBRATOR/'brightness').write_text('0')
self.deadline = now + duration / 1000
return
(VIBRATOR/'brightness').write_text('0')
vibration = Vibration()
def control(p):
if p.get('operation') == 'screen':
enabled, level = p.get('enabled'), p.get('level')
if type(enabled) is not bool or type(level) not in (int, float) or not 0 <= level <= 1:
raise ValueError('INVALID_SCREEN')
maximum = int((BACKLIGHT/'max_brightness').read_text())
(BACKLIGHT/'brightness').write_text(str(max(1, round(maximum * level)) if enabled else 0))
elif p.get('operation') == 'vibrate':
vibration.start(p.get('pattern'))
elif p.get('operation') == 'volume':
level = p.get('level')
if type(level) not in (int, float) or not 0 <= level <= 1:
raise ValueError('INVALID_VOLUME')
env = {**os.environ, 'XDG_RUNTIME_DIR': '/run/user/32011',
'PULSE_SERVER': 'unix:/run/user/32011/pulse/native'}
subprocess.run(['/usr/bin/pactl',
'set-sink-volume', '@DEFAULT_SINK@', str(round(level*100))+'%'],
env=env, user=32011, group=32011, extra_groups=[], check=True, timeout=3, capture_output=True)
elif p.get('operation') == 'power' and p.get('action') in ('reboot', 'poweroff'):
subprocess.run(['/usr/bin/systemctl', '--no-block', p['action']], check=True, timeout=3, capture_output=True)
else:
raise ValueError('NOT_SUPPORTED')
return True
def serve():
SOCKET.parent.mkdir(parents=True, exist_ok=True)
SOCKET.unlink(missing_ok=True)
state = {'session': str(uuid.uuid4()), 'sequence': 0, 'events': []}
def publish():
state['recordedAt'] = time.time()
temporary = EVENTS.with_suffix('.tmp')
temporary.write_text(json.dumps(state))
temporary.chmod(0o644)
temporary.replace(EVENTS)
publish()
with selectors.DefaultSelector() as selector, socket.socket(socket.AF_UNIX) as server:
server.bind(str(SOCKET)); SOCKET.chmod(0o660)
import grp
os.chown(SOCKET, 0, grp.getgrnam('droidian').gr_gid)
server.listen(8); selector.register(server, selectors.EVENT_READ)
for device in Path('/sys/class/input').glob('event*'):
if (device/'device/name').read_text().strip() not in ('qpnp_pon', 'gpio-keys'):
continue
fd = os.open('/dev/input/'+device.name, os.O_RDONLY | os.O_NONBLOCK)
selector.register(fd, selectors.EVENT_READ)
while True:
timeout = max(0, min(1, vibration.deadline - time.monotonic())) if vibration.deadline is not None else 1
for key, _ in selector.select(timeout):
if key.fileobj is server:
conn, _ = server.accept()
with conn:
conn.settimeout(2)
try:
raw = conn.makefile('rb').readline(1024)
result = control(json.loads(raw))
reply = {'ok': True, 'result': result}
except Exception:
reply = {'ok': False, 'error': 'HARDWARE_REQUEST_FAILED'}
conn.sendall((json.dumps(reply)+'\n').encode())
else:
data = os.read(key.fd, INPUT.size*64)
for offset in range(0, len(data), INPUT.size):
_, _, kind, code, value = INPUT.unpack_from(data, offset)
event = key_event(kind, code, value)
if event:
# Always allow a physical press to recover a black display,
# even if Gaia has stopped responding.
if code == 116 and value == 1 and int((BACKLIGHT/'brightness').read_text()) == 0:
control({'operation':'screen', 'enabled':True, 'level':0.5})
state['sequence'] += 1
state['events'].append({'id':state['sequence'], 'type':event})
state['events'] = state['events'][-64:]
if vibration.deadline is not None:
vibration.advance(time.monotonic())
publish()
def client():
raw = sys.stdin.read(1024)
with socket.socket(socket.AF_UNIX) as sock:
sock.settimeout(5); sock.connect(str(SOCKET))
sock.sendall(raw.encode()+b'\n')
print(sock.makefile('r').readline(2048).strip())
if __name__ == '__main__':
if '--stop-vibration' in sys.argv:
(VIBRATOR/'brightness').write_text('0')
elif '--client' in sys.argv:
client()
else:
try:
(VIBRATOR/'brightness').write_text('0')
serve()
finally:
(VIBRATOR/'brightness').write_text('0')

View file

@ -0,0 +1,216 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Route modem calls through the Droid audio HAL; never dial or record audio.
Profile/parking sequence: mer-hybris/pulseaudio-modules-droid README.
Only the physical earpiece is selected automatically, never the loudspeaker.
"""
import importlib.util
import json
import os
import re
import socket
import struct
import sys
from pathlib import Path
import subprocess
import time
def names(items):
return set(items) if isinstance(items, dict) else {p['name'] for p in items}
def parse_devices(text):
"""Read pactl's C-locale listing, including PulseAudio versions before JSON."""
devices, current, section = [], None, None
scalar = {'Name':'name', 'Owner Module':'owner_module', 'Active Profile':'active_profile',
'Active Port':'active_port', 'Mute':'mute'}
for line in text.splitlines():
header = re.fullmatch(r'(?:Card|Sink|Source|Sink Input) #(\d+)', line)
if header:
current = {'index':int(header[1]), 'ports':{}, 'profiles':{}, 'properties':{}}
devices.append(current); section = None
elif current is not None and line.startswith('\t') and not line.startswith('\t\t'):
key, separator, value = line.strip().partition(':')
section = key.lower() if key in ('Ports', 'Profiles', 'Properties') else None
if key in scalar and separator:
value = value.strip()
if key == 'Owner Module': value = int(value)
elif key == 'Mute': value = value == 'yes'
current[scalar[key]] = value
elif current is not None and line.startswith('\t\t') and not line.startswith('\t\t\t'):
value = line.strip()
if section in ('ports', 'profiles') and ':' in value:
current[section][value.split(':', 1)[0]] = {}
elif section == 'properties' and ' = ' in value:
key, value = value.split(' = ', 1)
current['properties'][key] = value.strip('"')
return devices
def pactl(*args):
result = subprocess.run(
['/usr/bin/pactl', *args], capture_output=True, text=True, timeout=5,
user=32011, group=32011, extra_groups=[],
env={**os.environ, 'XDG_RUNTIME_DIR':'/run/user/32011',
'PULSE_SERVER':'unix:/run/user/32011/pulse/native', 'LC_ALL':'C'})
if result.returncode:
raise RuntimeError('CALL_AUDIO_COMMAND_FAILED')
return result.stdout
class CallAudio:
def __init__(self, command=pactl):
self.command = command
self.saved = None
self.active = False
self.speaker = False
self.muted = False
self.volume = 0.6
def devices(self, kind):
return parse_devices(self.command('list', kind))
def enter(self):
if self.active:
return
if self.saved is not None:
self.leave()
card = next((c for c in self.devices('cards')
if c['name'] == 'droid_card.primary' and 'voicecall' in names(c['profiles'])), None)
if card is None:
raise RuntimeError('CALL_AUDIO_CARD_UNAVAILABLE')
sinks = self.devices('sinks')
sink = next((s for s in sinks if s.get('owner_module') == card['owner_module'] and
'output-earpiece' in names(s['ports']) and 'output-parking' in names(s['ports'])), None)
source = next((s for s in self.devices('sources') if s['name'] == 'source.droid'), None)
if sink is None or source is None:
raise RuntimeError('CALL_AUDIO_EARPIECE_UNAVAILABLE')
self.saved = {'card':card['name'], 'profile':card['active_profile'],
'sink':sink['name'], 'port':sink['active_port'], 'sinkMute':sink['mute'],
'source':source['name'], 'sourceMute':source['mute']}
try:
self.command('set-card-profile', card['name'], 'voicecall')
self.command('set-sink-port', sink['name'], 'output-parking')
self.command('set-sink-port', sink['name'], 'output-earpiece')
self.command('set-sink-mute', sink['name'], '0')
self.command('set-source-mute', source['name'], '0')
# voice_virtual_stream creates this HAL volume-control stream.
for stream in self.devices('sink-inputs'):
if stream.get('properties', {}).get('media.role') == 'phone':
self.command('set-sink-input-volume', str(stream['index']), str(round(self.volume * 100)) + '%')
self.active = True
except Exception:
self.leave()
raise
def control(self, request):
if not isinstance(request, dict) or set(request) - {'operation', 'speaker', 'muted', 'volume'}:
raise ValueError('INVALID_AUDIO_REQUEST')
if request.get('operation') == 'status':
return {'active': self.active, 'speaker': self.speaker, 'muted': self.muted, 'volume': self.volume}
if request.get('operation') != 'set' or not self.active:
raise ValueError('CALL_AUDIO_NOT_ACTIVE')
for key in ('speaker', 'muted'):
if key in request and type(request[key]) is not bool:
raise ValueError('INVALID_AUDIO_REQUEST')
if 'volume' in request and (type(request['volume']) not in (int, float) or not 0 <= request['volume'] <= 1):
raise ValueError('INVALID_AUDIO_REQUEST')
if 'speaker' in request:
port = 'output-speaker' if request['speaker'] else 'output-earpiece'
sink = next((s for s in self.devices('sinks') if s['name'] == self.saved['sink']), None)
if not sink or port not in names(sink['ports']):
raise RuntimeError('CALL_AUDIO_PORT_UNAVAILABLE')
self.command('set-sink-port', sink['name'], 'output-parking')
self.command('set-sink-port', sink['name'], port)
self.speaker = request['speaker']
if 'muted' in request:
self.command('set-source-mute', self.saved['source'], str(int(request['muted'])))
self.muted = request['muted']
if 'volume' in request:
streams = [s for s in self.devices('sink-inputs') if s.get('properties', {}).get('media.role') == 'phone']
if not streams:
raise RuntimeError('CALL_AUDIO_VOLUME_UNAVAILABLE')
for stream in streams:
self.command('set-sink-input-volume', str(stream['index']), str(round(request['volume'] * 100)) + '%')
self.volume = request['volume']
return self.control({'operation': 'status'})
def leave(self):
if self.saved is None:
return
self.active = False
state = self.saved
self.command('set-card-profile', state['card'], state['profile'])
self.command('set-sink-port', state['sink'], 'output-parking')
self.command('set-sink-port', state['sink'], state['port'])
self.command('set-sink-mute', state['sink'], str(int(state['sinkMute'])))
self.command('set-source-mute', state['source'], str(int(state['sourceMute'])))
self.saved = None
self.speaker = False
self.muted = False
def update(self, calls):
if any(c['state'] in ('dialing', 'alerting', 'active', 'held') for c in calls):
self.enter()
else:
self.leave()
def main():
spec = importlib.util.spec_from_file_location('radio', Path(__file__).with_name('radio-control.py'))
radio = importlib.util.module_from_spec(spec); spec.loader.exec_module(radio)
route = CallAudio()
target = Path('/run/tundra-call-audio/status.json')
path = target.with_name('control.sock')
path.unlink(missing_ok=True)
with socket.socket(socket.AF_UNIX) as server:
server.bind(str(path))
os.chown(path, 0, 32011)
path.chmod(0o660)
server.listen(4)
server.settimeout(0.3)
next_poll = 0
while True:
if time.monotonic() >= next_poll:
try:
route.update(radio.request({'version':1, 'operation':'calls'}))
state = {**route.control({'operation':'status'}), 'error':None}
except Exception as error:
state = {'active':route.active, 'error':str(error)}
state.update(routed=route.active, recordedAt=time.time())
tmp = target.with_suffix('.tmp'); tmp.write_text(json.dumps(state)); tmp.replace(target)
next_poll = time.monotonic() + 1
try:
conn, _ = server.accept()
except socket.timeout:
continue
with conn:
conn.settimeout(2)
try:
_, uid, _ = struct.unpack('3i', conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
if uid not in (0, 32011):
raise ValueError('PERMISSION_DENIED')
line = conn.makefile('rb').readline(1024)
if not line.endswith(b'\n'):
raise ValueError('INVALID_AUDIO_REQUEST')
result = {'result':route.control(json.loads(line))}
except Exception as error:
result = {'error':str(error)}
try:
conn.sendall((json.dumps(result)+'\n').encode())
except OSError:
pass
def client():
with socket.socket(socket.AF_UNIX) as sock:
sock.settimeout(8)
sock.connect('/run/tundra-call-audio/control.sock')
sock.sendall(sys.stdin.buffer.read(1024).rstrip(b'\n') + b'\n')
print(sock.makefile('r').readline(4096))
if __name__ == '__main__':
client() if '--client' in sys.argv else main()

View file

@ -0,0 +1,269 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Bounded libhybris preview worker. Frames stay in memory; no microphone access.
The parent owns stdin. EOF or five seconds without a request closes the sensor.
A pbuffer and external texture are required even for the software NV21 callback.
"""
import argparse
import struct
import ctypes as C
import json
import os
import select
import sys
import time
def function(lib, name, args, result=None):
value = getattr(lib, name)
value.argtypes, value.restype = args, result
return value
class Listener(C.Structure):
_fields_ = [(name, C.c_void_p) for name in
('error', 'shutter', 'focus', 'zoom', 'raw', 'jpeg', 'texture', 'context', 'preview')]
class Camera:
width, height = 640, 480
def __init__(self, index):
self.camera = None
self.display = self.surface = self.context = None
self.frame = None
self.jpeg = None
self.focus_done = False
self.count = self.errors = 0
self.texture_pending = False
os.environ['HYBRIS_EGLPLATFORM'] = 'null'
os.environ['__EGL_VENDOR_LIBRARY_FILENAMES'] = '/usr/share/glvnd/egl_vendor.d/10_libhybris.json'
self.egl = C.CDLL('libEGL.so.1')
self.gl = C.CDLL('libGLESv2.so.2')
self.lib = C.CDLL('libcamera.so.1')
P, I, U = C.c_void_p, C.c_int, C.c_uint
self.display = function(self.egl, 'eglGetDisplay', [P], P)(None)
if not function(self.egl, 'eglInitialize', [P, P, P], U)(self.display, None, None):
raise RuntimeError('CAMERA_EGL_INIT_FAILED')
attrs = (I * 13)(0x3033, 1, 0x3040, 4, 0x3024, 8, 0x3023, 8, 0x3022, 8, 0x3021, 8, 0x3038)
config, count = P(), I()
function(self.egl, 'eglChooseConfig', [P, P, P, I, P], U)(self.display, attrs, C.byref(config), 1, C.byref(count))
if not count.value:
raise RuntimeError('CAMERA_EGL_CONFIG_FAILED')
function(self.egl, 'eglBindAPI', [U], U)(0x30A0)
self.context = function(self.egl, 'eglCreateContext', [P, P, P, P], P)(self.display, config, None, (I * 3)(0x3098, 2, 0x3038))
self.surface = function(self.egl, 'eglCreatePbufferSurface', [P, P, P], P)(self.display, config, (I * 5)(0x3057, 16, 0x3056, 16, 0x3038))
if not function(self.egl, 'eglMakeCurrent', [P, P, P, P], U)(self.display, self.surface, self.surface, self.context):
raise RuntimeError('CAMERA_EGL_CONTEXT_FAILED')
texture = U()
function(self.gl, 'glGenTextures', [I, P])(1, C.byref(texture))
@C.CFUNCTYPE(None, P, U, P)
def preview(data, size, _context):
if size == self.width * self.height * 3 // 2:
self.frame = C.string_at(data, size)
self.count += 1
@C.CFUNCTYPE(None, P)
def error(_context):
self.errors += 1
@C.CFUNCTYPE(None, P)
def available(_context):
self.texture_pending = True
@C.CFUNCTYPE(None, P)
def focused(_context):
self.focus_done = True
@C.CFUNCTYPE(None, P, U, P)
def jpeg(data, size, _context):
if 0 < size <= 32 * 1024 * 1024:
self.jpeg = C.string_at(data, size)
self.callbacks = (preview, error, available, focused, jpeg)
self.listener = Listener()
for name, callback in [('preview', preview), ('error', error), ('texture', available), ('focus', focused), ('jpeg', jpeg)]:
setattr(self.listener, name, C.cast(callback, P).value)
self.camera = function(self.lib, 'android_camera_connect_by_id', [I, P], P)(index, C.byref(self.listener))
if not self.camera:
raise RuntimeError('CAMERA_CONNECT_FAILED')
facing, orientation = I(), I()
function(self.lib, 'android_camera_get_device_info', [I, P, P], I)(index, C.byref(facing), C.byref(orientation))
self.orientation = orientation.value
function(self.lib, 'android_camera_set_preview_size', [P, I, I])(self.camera, self.width, self.height)
function(self.lib, 'android_camera_set_preview_format', [P, I])(self.camera, 1) # YUV420SP / NV21
function(self.lib, 'android_camera_set_preview_texture', [P, I])(self.camera, texture.value)
function(self.lib, 'android_camera_set_preview_callback_mode', [P, I], I)(self.camera, 1)
function(self.lib, 'android_camera_start_preview', [P])(self.camera)
self.parameters = self.read_parameters()
self.picture_sizes = [tuple(map(int, size.split('x'))) for size in self.parameters.get('picture-size-values', '').split(',') if 'x' in size]
self.picture_size = max(self.picture_sizes, key=lambda size:size[0]*size[1])
self.zoom_ratios = [int(n)/100 for n in self.parameters.get('zoom-ratios', '100').split(',')]
def read_parameters(self):
# libhybris exposes the flattened parameters through a stdout dump.
# Capture it in RAM so it cannot corrupt the worker's binary protocol.
libc = C.CDLL(None)
fd = os.memfd_create('camera-parameters')
saved = os.dup(1)
try:
libc.fflush(None)
os.dup2(fd, 1)
function(self.lib, 'android_camera_dump_parameters', [C.c_void_p])(self.camera)
libc.fflush(None)
finally:
os.dup2(saved, 1)
os.close(saved)
try:
os.lseek(fd, 0, 0)
raw = os.read(fd, 65536).decode()
return dict(item.split('=', 1) for item in raw.strip().split(';') if '=' in item)
finally:
os.close(fd)
def metadata(self):
return {'ready': True, 'width': self.width, 'height': self.height,
'orientation': self.orientation, 'format': 'nv21',
'pictureSizes': [{'width':w,'height':h} for w,h in self.picture_sizes],
'focusModes': self.parameters.get('focus-mode-values', 'fixed').split(','),
'maxFocusAreas': min(1,int(self.parameters.get('max-num-focus-areas', 0))),
'zoomRatios': self.zoom_ratios}
def control(self, command):
P, I = C.c_void_p, C.c_int
action = command.get('action')
if action == 'zoom':
ratio = command.get('zoom')
if not isinstance(ratio, (int,float)) or not self.zoom_ratios[0] <= ratio <= self.zoom_ratios[-1]:
raise ValueError('CAMERA_INVALID_ZOOM')
index = min(range(len(self.zoom_ratios)), key=lambda i:abs(self.zoom_ratios[i]-ratio))
function(self.lib,'android_camera_set_zoom',[P,I])(self.camera,index)
actual = int(self.read_parameters().get('zoom',index))
if not 0 <= actual < len(self.zoom_ratios): raise RuntimeError('CAMERA_INVALID_ZOOM_REPLY')
return {'zoom':self.zoom_ratios[actual]}
if action == 'focus':
if 'auto' not in self.metadata()['focusModes']:
raise ValueError('CAMERA_FOCUS_UNAVAILABLE')
function(self.lib,'android_camera_stop_autofocus',[P])(self.camera)
function(self.lib,'android_camera_set_auto_focus_mode',[P,I])(self.camera,2)
area = command.get('area')
if area is not None:
values = [area.get(k) for k in ('top','left','bottom','right')]
if any(not isinstance(n,int) or not -1000 <= n <= 1000 for n in values) or values[0]>=values[2] or values[1]>=values[3]:
raise ValueError('CAMERA_INVALID_FOCUS_AREA')
if not self.metadata()['maxFocusAreas']:
raise ValueError('CAMERA_FOCUS_AREA_UNAVAILABLE')
region = (I*5)(*values,1000)
function(self.lib,'android_camera_set_focus_region',[P,P])(self.camera,region)
else:
function(self.lib,'android_camera_reset_focus_region',[P])(self.camera)
self.focus_done = False
function(self.lib,'android_camera_start_autofocus',[P])(self.camera)
deadline = time.monotonic()+4
while not self.focus_done and time.monotonic()<deadline:
self.tick(); time.sleep(.01)
if not self.focus_done:
raise RuntimeError('CAMERA_FOCUS_TIMEOUT')
return {'completed':True}
if action == 'picture':
size = command.get('size') or dict(zip(('width','height'),self.picture_size))
pair = (size.get('width'),size.get('height'))
if pair not in self.picture_sizes:
raise ValueError('CAMERA_INVALID_PICTURE_SIZE')
function(self.lib,'android_camera_set_picture_size',[P,I,I])(self.camera,*pair)
function(self.lib,'android_camera_set_jpeg_quality',[P,I])(self.camera,95)
function(self.lib,'android_camera_set_rotation',[P,I])(self.camera,self.orientation)
self.jpeg = None
try:
function(self.lib,'android_camera_take_snapshot',[P])(self.camera)
deadline = time.monotonic()+12
while self.jpeg is None and time.monotonic()<deadline:
self.tick(); time.sleep(.01)
if self.jpeg is None:
raise RuntimeError('CAMERA_CAPTURE_TIMEOUT')
return self.jpeg
finally:
function(self.lib,'android_camera_start_preview',[P])(self.camera)
raise ValueError('CAMERA_INVALID_COMMAND')
def tick(self):
if self.texture_pending:
self.texture_pending = False
function(self.lib, 'android_camera_update_preview_texture', [C.c_void_p])(self.camera)
def close(self):
P = C.c_void_p
if self.camera:
function(self.lib, 'android_camera_stop_preview', [P])(self.camera)
function(self.lib, 'android_camera_disconnect', [P])(self.camera)
self.camera = None
if self.display:
function(self.egl, 'eglMakeCurrent', [P, P, P, P], C.c_uint)(self.display, None, None, None)
if self.surface:
function(self.egl, 'eglDestroySurface', [P, P], C.c_uint)(self.display, self.surface)
if self.context:
function(self.egl, 'eglDestroyContext', [P, P], C.c_uint)(self.display, self.context)
function(self.egl, 'eglTerminate', [P], C.c_uint)(self.display)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--camera', type=int, choices=(0, 1), default=0)
parser.add_argument('--probe', action='store_true', help='Count frames for five seconds, retain no images')
args = parser.parse_args()
output = os.fdopen(os.dup(1), 'wb', buffering=0)
os.dup2(2, 1) # HAL callbacks can print; keep stdout protocol uncontaminated.
camera = None
try:
camera = Camera(args.camera)
started = last_request = time.monotonic()
if not args.probe:
metadata = json.dumps(camera.metadata()).encode()
output.write(struct.pack('<I', len(metadata)) + metadata)
output.flush()
while time.monotonic() - last_request < 5:
camera.tick()
if args.probe:
time.sleep(.01)
continue
if not select.select([sys.stdin], [], [], .01)[0]:
continue
command = sys.stdin.readline(4096)
if not command or len(command)>=4096:
break
last_request = time.monotonic()
if command == 'frame\n':
data = camera.frame or b''
output.write(struct.pack('<II', camera.count & 0xffffffff, len(data)))
output.write(data)
else:
try:
result = camera.control(json.loads(command))
if isinstance(result,bytes):
data = json.dumps({'ok':True,'jpegLength':len(result)}).encode()
else:
data = json.dumps({'ok':True,'result':result}).encode()
except (ValueError,RuntimeError,TypeError,AttributeError) as error:
result = None
data = json.dumps({'ok':False,'error':str(error)}).encode()
output.write(struct.pack('<I',len(data))+data)
if isinstance(result,bytes):
output.write(result)
output.flush()
last_request = time.monotonic()
if args.probe:
output.write((json.dumps({'camera': args.camera, 'frames': camera.count, 'errors': camera.errors,
'width': camera.width, 'height': camera.height, 'orientation': camera.orientation,
'seconds': round(time.monotonic() - started, 2), 'imagesRetained': False})+'\n').encode())
if not camera.count or camera.errors:
return 1
return 0
finally:
if camera:
camera.close()
if __name__ == '__main__':
sys.exit(main())

View file

@ -0,0 +1,98 @@
# SPDX-License-Identifier: MPL-2.0
"""Reduce Android init to display HAL services for the read-only Sargo trial.
Input files retain their upstream copyright notices. Removed commands and services
are reported; this is not a general Android init parser or a production policy.
"""
import shlex
SERVICES = frozenset(('ueventd','logd','servicemanager','hwservicemanager','vndservicemanager',
'vendor.hwcomposer-2-2','vendor.qti.hardware.display.allocator','vendor.configstore-hal',
'vendor.power-hal-1-3','vendor.thermal-engine','thermal-hal-1-1','hidl_memory'))
COMMANDS = frozenset(('mkdir','chmod','chown','write','setprop','symlink','copy',
'restorecon','restorecon_recursive','trigger','class_start','class_stop',
'load_system_props','load_persist_props','export','setrlimit','hostname','domainname','loglevel','sysclktz','start','stop','restart'))
PSEUDO_FS = frozenset(('cgroup','cgroup2','cpuset','tmpfs','proc','sysfs','debugfs','pstore','bpf'))
# The native network trial supplies rmt_storage with RAM copies of NV files.
# It retains the blanket denial of block-device access for this container.
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',
'vendor.audio-hal-2-0', 'vendor.camera-provider-2-4',
'camera_service', 'minimedia', 'minisf', 'miniaf'))
def is_init_script(path):
# ueventd uses a different grammar: these files define device permissions.
return not path.name.startswith('ueventd')
def command_allowed(tokens):
if not tokens:
return True
op=tokens[0]
if op=='onrestart':
return command_allowed(tokens[1:])
if op in ('start','stop','restart'):
return len(tokens)==2 and tokens[1] in SERVICES
if op=='mount':
return len(tokens)>3 and tokens[1] in PSEUDO_FS and not tokens[2].startswith('/dev/')
if op not in COMMANDS:
return False
if any('/dev/block' in t or '/dev/mmcblk' in t or '/sys/kernel/config' in t or '/sys/class/android_usb' in t or '/config/usb_gadget' in t or '/proc/sys/kernel/panic' in t or '/proc/sysrq-trigger' in t for t in tokens[1:]):
return False
if op=='setprop' and len(tokens)>2 and tokens[1].startswith('ctl.'):
return tokens[1] in ('ctl.start','ctl.stop','ctl.restart') and tokens[2] in SERVICES
return True
def logical_lines(text):
pending = ''
first = 1
for number, line in enumerate(text.splitlines(), 1):
if not pending: first = number
pending += line
if pending.endswith('\\'):
pending = pending[:-1]+' '
continue
try:
shlex.split(pending.strip(), comments=True)
except ValueError:
pending += '\n'
continue
yield first, pending
pending = ''
if pending:
raise ValueError('Unterminated Android init command at line '+str(first))
def filter_rc(text):
output=[];removed=[];kept=[];section=None;keep=True
for number,line in logical_lines(text):
stripped=line.strip()
if not stripped or stripped.startswith('#'):
if keep:output.append(line)
continue
tokens=shlex.split(stripped,comments=True)
if not line[0].isspace():
section=tokens[0];keep=True
if section=='service':
keep=len(tokens)>2 and tokens[1] in SERVICES and not any('_DISABLED' in t for t in tokens[2:])
if keep:kept.append(tokens[1])
elif section=='import':
# USB, recovery, and Android app processes are outside this trial.
keep=not any(word in stripped for word in ('usb','recovery','zygote'))
elif section!='on':
keep=False
if keep:output.append(line)
else:removed.append({'line':number,'text':line})
continue
accepted=keep and (section=='service' or command_allowed(tokens))
# Keep failed trial services inspectable instead of rebooting the device.
if section=='service' and tokens[0]=='critical':accepted=False
if section=='service' and tokens[0]=='onrestart':accepted=keep and command_allowed(tokens)
if accepted:output.append(line)
else:removed.append({'line':number,'text':line})
return '\n'.join(output)+'\n',{'services':kept,'removed':removed}

View file

@ -0,0 +1,64 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Publish read-only hardware state. No SSIDs, IP addresses or modem identifiers."""
import argparse
import json
import os
from pathlib import Path
import subprocess
import time
def battery_state(root=Path('/sys/class/power_supply/battery')):
try:
level = int((root/'capacity').read_text().strip())
status = (root/'status').read_text().strip()
if not 0 <= level <= 100 or status not in ('Charging', 'Discharging', 'Full', 'Not charging'):
return None
return {'level': level/100, 'charging': status in ('Charging', 'Full'), 'status': status}
except (OSError, ValueError):
return None
def wifi_state():
try:
result = subprocess.run(['nmcli', '-g', 'GENERAL.STATE', 'device', 'show', 'wlan0'],
capture_output=True, text=True, timeout=3,
env={**os.environ, 'LC_ALL': 'C'})
if result.returncode:
return {'available': False, 'state': 'unavailable'}
state = int(result.stdout.split()[0])
names = {10:'unmanaged', 20:'unavailable', 30:'disconnected', 40:'connecting',
50:'connecting', 60:'authentication', 70:'connecting', 80:'connecting',
90:'connecting', 100:'connected', 110:'disconnecting', 120:'failed'}
return {'available': state >= 30, 'state': names.get(state, 'unknown')}
except (OSError, ValueError, IndexError, subprocess.TimeoutExpired):
return {'available': False, 'state': 'unavailable'}
def snapshot():
# Do not D-Bus-activate NetworkManager from an otherwise display-only boot.
wifi = wifi_state() if Path('/etc/tundra/network-enabled').is_file() else {
'available': False, 'state': 'unavailable'}
return {'schema': 1, 'recordedAt': time.time(), 'battery': battery_state(), 'wifi': wifi}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--once', action='store_true')
args = parser.parse_args()
if args.once:
print(json.dumps(snapshot()))
return
output = Path('/run/tundra-hardware/status.json')
output.parent.mkdir(mode=0o755, exist_ok=True)
while True:
temporary = output.with_suffix('.tmp')
temporary.write_text(json.dumps(snapshot())+'\n')
temporary.chmod(0o644)
temporary.replace(output)
time.sleep(5)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,39 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Restrict the legacy HAL to character devices before creating its container.
Sargo's 4.9 kernel rejects LXC's cgroup2 device BPF program. A separate v1
devices controller is required; LXC's usual rules alone do not enforce it.
"""
import errno
import os
from pathlib import Path
import subprocess
import sys
def main():
if 'tundra.rescue=1' not in Path('/proc/cmdline').read_text():
raise RuntimeError('Temporary boot flag missing')
controller=Path('/run/tundra-device-controller')
controller.mkdir(exist_ok=True)
if not (controller/'devices.deny').exists():
subprocess.run(['mount','-t','cgroup','-o','devices','devices',str(controller)],check=True)
group=controller/'hal'
group.mkdir(exist_ok=True)
(group/'devices.deny').write_text('b *:* rwm')
(group/'cgroup.procs').write_text(str(os.getpid()))
# Test the actual open, not devices.list: this kernel lists a default allow
# rule even when the blacklist is active. No bytes are written or read.
try:
descriptor=os.open('/dev/mmcblk0',os.O_RDONLY)
except OSError as error:
if error.errno!=errno.EPERM:raise
else:
os.close(descriptor)
raise RuntimeError('Block device restriction was not enforced')
with open('/dev/null','wb'):pass
os.execv('/usr/bin/lxc-start',['lxc-start']+sys.argv[1:])
if __name__=='__main__':main()

View file

@ -0,0 +1,101 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Configure isolated oFono/PulseAudio diagnostics; no call or SMS on startup."""
from pathlib import Path
def prepare(root, write):
write('etc/tundra/hardware-enabled', 'sargo\n')
with (root/'etc/group').open('a') as groups:
groups.write('audio:x:1005:droidian\ncamera:x:1006:droidian\nradio:x:1001:\nmedia:x:1013:droidian\n')
write('etc/ofono/binder.conf', '[Settings]\nExpectSlots = slot1\n\n[slot1]\npath = /ril_0\nslot = 0\n')
# Only the diagnostic root process may operate the modem. Gaia needs its
# separate, permission-checked broker before telephony can be exposed.
write('etc/dbus-1/system.d/ofono.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 context="default"><deny send_destination="org.ofono"/></policy>
<policy user="root"><allow own="org.ofono"/><allow send_destination="org.ofono"/></policy>
</busconfig>
''')
write('etc/systemd/system/ofono.service', '''[Unit]
Description=Tundra modem diagnostic service
Requires=tundra-hal.service dbus.service
After=tundra-hal.service dbus.service
[Service]
Type=dbus
BusName=org.ofono
ExecStartPre=/usr/bin/binder-wait android.hardware.radio@1.0::IRadio/slot1
ExecStart=/usr/sbin/ofonod --nodetach
Restart=on-failure
RestartSec=5
''')
write('etc/systemd/system/tundra-radio.service', '''[Unit]
Description=Tundra restricted oFono bridge
Requires=dbus.service ofono.service
After=dbus.service ofono.service
[Service]
Type=simple
ExecStart=/usr/bin/python3 /etc/tundra/radio-control.py --server
RuntimeDirectory=tundra-radio
RuntimeDirectoryMode=0755
Restart=on-failure
RestartSec=2
NoNewPrivileges=yes
ProtectHome=yes
ProtectSystem=strict
StateDirectory=tundra/sms
StateDirectoryMode=0700
ReadWritePaths=/run/tundra-radio /var/lib/tundra/sms
''')
write('etc/pulse/client.conf', 'autospawn = no\n')
write('etc/pulse/daemon.conf', 'exit-idle-time = -1\ndefault-sample-rate = 48000\n')
write('etc/pulse/default.pa', '''.fail
load-module module-native-protocol-unix
load-module module-droid-card rate=48000 voice_virtual_stream=true
load-module module-droid-hidl
load-module module-default-device-restore
load-module module-suspend-on-idle
''')
# Prefer AAL for the next camera integration tests. The GStreamer probe
# requests libstagefright_ccodec.so, absent from this Android 9 image.
# Selecting AAL alone does not prove that a preview reaches the sensor.
write('usr/lib/droidian/device/droidian-camera.conf',
'[General]\nblacklist=""\nbackend=aal\n')
write('etc/systemd/system/tundra-audio.service', '''[Unit]
Description=Tundra Sargo PulseAudio HAL bridge
Requires=tundra-hal.service systemd-logind.service
After=tundra-hal.service systemd-logind.service
[Service]
User=droidian
PAMName=tundra
Environment=XDG_RUNTIME_DIR=/run/user/32011
ExecStart=/usr/bin/pulseaudio -n --daemonize=no --exit-idle-time=-1 --log-target=journal --file=/etc/pulse/default.pa
Restart=on-failure
RestartSec=5
''')
write('etc/systemd/system/tundra-call-audio.service', '''[Unit]
Description=Tundra modem call audio routing
Requires=ofono.service tundra-audio.service
After=ofono.service tundra-audio.service
[Service]
ExecStart=/usr/bin/python3 /etc/tundra/call-audio.py
RuntimeDirectory=tundra-call-audio
Restart=on-failure
RestartSec=5
ProtectHome=yes
ProtectSystem=strict
ReadWritePaths=/run/tundra-call-audio
''')
(root/'var/lib/tundra/sms').mkdir(parents=True,exist_ok=True,mode=0o700)
write('etc/systemd/system/tundra-sms.service', '\n'.join([
'[Unit]','Description=Persistent incoming SMS inbox','Requires=ofono.service',
'After=ofono.service','[Service]',
'ExecStart=/usr/bin/python3 /etc/tundra/sms-inbox.py',
'UMask=0077','ProtectSystem=strict','ProtectHome=yes',
'StateDirectory=tundra/sms','StateDirectoryMode=0700',
'ReadWritePaths=/var/lib/tundra/sms','Restart=on-failure','RestartSec=3','']))
wants = root/'etc/systemd/system/tundra.target.wants'
for name in ('ofono', 'tundra-audio', 'ModemManager', 'tundra-radio', 'tundra-call-audio', 'tundra-sms'):
(wants/(name+'.service')).symlink_to('../'+name+'.service')

View file

@ -0,0 +1,240 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Generate native trial configuration inside an already staged runtime tree."""
import argparse
import runpy
from pathlib import Path
import shutil
def prepare(root, network=None, hardware=None, diagnostics=False):
if hardware is None:
hardware = (root/'usr/sbin/ofonod').is_file()
if network is None:
network = (root/'usr/sbin/NetworkManager').is_file()
if hardware and not network:
raise ValueError('Hardware diagnostics require the isolated NV/network setup')
if not (root/'opt/vulpes/trial/session.py').is_file():raise ValueError('Stage the common product first')
def write(name,text,mode=0o644):
p=root/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_text(text);p.chmod(mode)
fonts = Path(__file__).resolve().parents[2]/'gaia/shared/elements/vulpes-fonts'
destination = root/'usr/share/fonts/opentype/vulpes'
destination.mkdir(parents=True, exist_ok=True)
for source in sorted(fonts.glob('*.otf')):
shutil.copyfile(source, destination/source.name)
(destination/source.name).chmod(0o644)
for name in ('LICENSE-OFL', 'NOTICE', 'provenance.json'):
shutil.copyfile(fonts/name, destination/name)
write('etc/fonts/conf.d/00-vulpes-sans.conf', '''<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<fontconfig>
<alias binding="strong"><family>sans-serif</family><prefer><family>Fira Sans</family></prefer></alias>
<alias binding="strong"><family>FiraSans</family><prefer><family>Fira Sans</family></prefer></alias>
</fontconfig>
''')
write('etc/systemd/system/tundra-display.service.d/fonts.conf', '''[Service]
ExecStartPre=+/usr/bin/fc-cache -f /usr/share/fonts/opentype/vulpes
''')
write('etc/os-release','NAME="Tundra"\nID=tundra\nPRETTY_NAME="Tundra Sargo development trial"\n')
write('etc/hostname','vulpes-sargo\n')
write('etc/shells','/bin/sh\n/bin/bash\n/usr/bin/bash\n')
write('etc/pam.d/tundra','auth required pam_permit.so\naccount required pam_permit.so\nsession required pam_systemd.so\n')
write('etc/pam.d/other','auth required pam_deny.so\naccount required pam_deny.so\npassword required pam_deny.so\nsession required pam_deny.so\n')
write('etc/pam.d/common-account','account required pam_unix.so\n')
write('etc/pam.d/common-session-noninteractive','session required pam_unix.so\n')
write('etc/systemd/logind.conf','[Login]\nHandlePowerKey=ignore\nIdleAction=ignore\n')
write('etc/systemd/journald.conf','[Journal]\nStorage=volatile\nRuntimeMaxUse=32M\n')
write('etc/passwd','root:x:0:0:root:/root:/bin/bash\ndroidian:x:32011:32011:Vulpes trial:/home/droidian:/bin/bash\nsystem:x:1000:1000:Android HAL:/:/usr/sbin/nologin\n')
write('etc/group','root:x:0:\ndroidian:x:32011:\nsystem:x:1000:droidian\ngraphics:x:1003:droidian\nandroid_input:x:1004:droidian\nvideo:x:44:droidian\nrender:x:105:droidian\ninput:x:104:droidian\nnetdev:x:988:\n')
write('etc/shadow','root:!:20000:0:99999:7:::\ndroidian:!:20000:0:99999:7:::\nsystem:!:20000:0:99999:7:::\n',0o600)
write('etc/tundra/phoc.ini','[output:HWCOMPOSER-1]\nscale=3\n')
write('etc/udev/rules.d/90-tundra-display.rules','KERNEL=="kgsl*", GROUP="graphics", MODE="0660"\nKERNEL=="ion", GROUP="graphics", MODE="0660"\nKERNEL=="event*", GROUP="input", MODE="0660"\nKERNEL=="*binder", MODE="0666"\n')
# The Android camera subdevices are not generic V4L capture devices. The
# desktop v4l_id probe opens them before their HAL and crashes this kernel.
(root/'etc/udev/rules.d/60-persistent-v4l.rules').symlink_to('/dev/null')
write('etc/systemd/system/tundra.target','''[Unit]
Description=Tundra temporary hardware trial
Requires=basic.target tundra-display.service
After=basic.target tundra-display.service
AllowIsolate=yes
''')
# systemd does not consistently honor a --unit argument passed through
# switch_root on the Android-derived initramfs. Make the trial target the
# normal boot target as well, so the display starts without an SSH nudge.
default_target = root/'etc/systemd/system/default.target'
if default_target.exists() or default_target.is_symlink():
default_target.unlink()
default_target.symlink_to('tundra.target')
write('etc/systemd/system/tundra-hal.service','''[Unit]
Description=Curated Android display HAL
After=systemd-udev-trigger.service
[Service]
Type=simple
ExecStartPre=/usr/bin/python3 /etc/tundra/setup-hal.py
ExecStart=/usr/bin/python3 /etc/tundra/lxc-guard.py -F -n tundra-hal -f /etc/tundra/lxc.conf -o /run/tundra/lxc.log -l DEBUG
KillMode=control-group
TimeoutStopSec=15
''')
write('etc/systemd/system/tundra-display.service','''[Unit]
Description=Gaia and Gecko on the Sargo display
Requires=tundra-hal.service dbus.service systemd-logind.service
After=tundra-hal.service dbus.service systemd-logind.service systemd-user-sessions.service
Wants=systemd-user-sessions.service
[Service]
User=droidian
PAMName=tundra
WorkingDirectory=/home/droidian
Environment=XDG_RUNTIME_DIR=/run/user/32011
Environment=XDG_SESSION_TYPE=wayland
Environment=XDG_CURRENT_DESKTOP=Vulpes
Environment=LANG=C.UTF-8
Environment=VULPES_TUNDRA=1
Environment=VULPES_TUNDRA_RADIO=1
Environment=WLR_BACKENDS=hwcomposer,libinput
Environment=EGL_PLATFORM=hwcomposer
Environment=WLR_HWC_SKIP_VERSION_CHECK=1
ExecStartPre=+/usr/bin/python3 /etc/tundra/wait-hal.py
ExecStartPre=+chvt 7
ExecStart=/usr/bin/dbus-run-session -- /usr/bin/phoc --no-xwayland --socket=tundra-0 -C /etc/tundra/phoc.ini -E "/usr/bin/python3 /opt/vulpes/trial/session.py --own-compositor --hybris-egl"
TTYPath=/dev/tty7
TTYReset=yes
TTYVHangup=yes
StandardInput=tty-fail
StandardOutput=journal
StandardError=inherit
KillMode=control-group
TimeoutStartSec=90
TimeoutStopSec=15
''')
if diagnostics:
display = root/'etc/systemd/system/tundra-display.service'
display.write_text(display.read_text().replace('--own-compositor --hybris-egl',
'--own-compositor --hybris-egl --diagnostics'))
write('etc/tundra/wait-hal.py','''import subprocess,time
for _ in range(120):
p=subprocess.run(['/usr/bin/lxc-attach','-n','tundra-hal','--','/system/bin/getprop','init.svc.vendor.hwcomposer-2-2'],capture_output=True,text=True)
if p.stdout.strip()=='running':break
time.sleep(.5)
else:raise RuntimeError('Display HAL did not start')
''')
write('etc/tundra/lxc.conf','''lxc.rootfs.path = /android
lxc.uts.name = tundra-hal
lxc.net.0.type = empty
lxc.arch = aarch64
lxc.init.cmd = /init
lxc.apparmor.profile = unconfined
lxc.autodev = 0
lxc.cap.drop = mac_admin mac_override sys_boot
lxc.cgroup.devices.deny = b *:* rwm
lxc.cgroup2.devices.deny = b *:* rwm
lxc.cgroup2.memory.max = 536870912
lxc.mount.entry = tmpfs dev tmpfs nosuid 0 0
lxc.mount.entry = /dev/__properties__ dev/__properties__ none bind,create=dir 0 0
lxc.mount.entry = /dev/socket dev/socket none bind,create=dir 0 0
lxc.mount.entry = proc proc proc nodev,noexec,nosuid 0 0
lxc.mount.entry = sys sys sysfs nodev,noexec,nosuid 0 0
lxc.mount.entry = /android/data data none bind 0 0
lxc.mount.entry = /mnt mnt none rbind 0 0
''')
for name in ('hal_policy.py','setup-hal.py','lxc-guard.py','hardware-status.py','prepare-network.py','wifi-control.py','radio-control.py','sms-inbox.py','call-audio.py','camera-preview.py','buttons.py'):
shutil.copy2(Path(__file__).parent/name,root/'etc/tundra'/name)
write('etc/systemd/system/tundra-hardware.service', '''[Unit]
Description=Tundra read-only battery and network state
After=systemd-udev-trigger.service
[Service]
ExecStart=/usr/bin/python3 /etc/tundra/hardware-status.py
RuntimeDirectory=tundra-hardware
RuntimeDirectoryMode=0755
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
''')
wants = root/'etc/systemd/system/tundra.target.wants'
wants.mkdir(exist_ok=True)
(wants/'tundra-hardware.service').symlink_to('../tundra-hardware.service')
write('etc/systemd/system/tundra-buttons.service', '''[Unit]
Description=Sargo physical keys, backlight and audio volume
After=systemd-udev-trigger.service
[Service]
ExecStartPre=/usr/bin/python3 /etc/tundra/buttons.py --stop-vibration
ExecStart=/usr/bin/python3 /etc/tundra/buttons.py
ExecStopPost=/usr/bin/python3 /etc/tundra/buttons.py --stop-vibration
RuntimeDirectory=tundra-buttons
RuntimeDirectoryMode=0755
Restart=on-failure
RestartSec=2
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/run/tundra-buttons /sys/class/backlight -/sys/class/leds/vibrator
NoNewPrivileges=yes
''')
(wants/'tundra-buttons.service').symlink_to('../tundra-buttons.service')
# Older immutable images may not contain the networking packages yet.
if network:
write('etc/tundra/network-enabled', 'sargo\n')
display = root/'etc/systemd/system/tundra-display.service'
display.write_text(display.read_text().replace('Environment=VULPES_TUNDRA=1',
'Environment=VULPES_TUNDRA=1\nEnvironment=VULPES_TUNDRA_WIFI=1'))
with (root/'etc/tundra/lxc.conf').open('a') as config:
config.write('lxc.mount.entry = /run/tundra/nv-block dev/block none bind,create=dir 0 0\n')
write('etc/NetworkManager/NetworkManager.conf', '''[main]
plugins=keyfile
dhcp=internal
dns=default
auth-polkit=root-only
[keyfile]
unmanaged-devices=*,except:interface-name:wlan0
[device]
wifi.scan-rand-mac-address=no
[connection]
wifi.cloned-mac-address=permanent
[connectivity]
enabled=false
''')
write('etc/systemd/system/tundra-network.service', '''[Unit]
Description=Sargo Wi-Fi driver
Requires=tundra-hal.service
After=tundra-hal.service
Before=NetworkManager.service
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /etc/tundra/prepare-network.py
TimeoutStartSec=180
RemainAfterExit=yes
''')
write('etc/systemd/system/NetworkManager.service.d/tundra.conf', '''[Unit]
Requires=tundra-network.service
After=tundra-network.service
''')
# Wi-Fi profiles live in the private persistent root overlay.
(wants/'NetworkManager.service').symlink_to('/usr/lib/systemd/system/NetworkManager.service')
(root/'etc/systemd/system/dbus-fi.w1.wpa_supplicant1.service').symlink_to('/usr/lib/systemd/system/wpa_supplicant.service')
(root/'etc/NetworkManager/system-connections').mkdir(parents=True, exist_ok=True)
write('etc/systemd/system/tundra-wifi.service', '''[Unit]
Description=Vulpes native Wi-Fi control
Requires=NetworkManager.service
After=NetworkManager.service
[Service]
ExecStart=/usr/bin/python3 /etc/tundra/wifi-control.py
Group=droidian
RuntimeDirectory=tundra-wifi
RuntimeDirectoryMode=0750
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
ReadWritePaths=/run /etc/NetworkManager/system-connections
Restart=on-failure
RestartSec=5
''')
(wants/'tundra-wifi.service').symlink_to('../tundra-wifi.service')
if hardware:
runpy.run_path(str(Path(__file__).with_name('prepare-hardware.py')))['prepare'](root, write)
# Generate the initial machine identity locally. Persistent boots retain it.
write('etc/machine-id', '')
for directory in [root/'etc', *[p for p in (root/'etc').rglob('*') if p.is_dir()]]:
directory.chmod(0o755)
if __name__=='__main__':
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--root',type=Path,required=True);a=p.parse_args();prepare(a.root.resolve())

View file

@ -0,0 +1,40 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Activate the Sargo WLAN driver after the read-only vendor mount exists."""
from pathlib import Path
import errno
import time
def main():
if 'tundra.rescue=1' not in Path('/proc/cmdline').read_text():
raise RuntimeError('Temporary Tundra boot required')
firmware = Path('/vendor/firmware')
if not firmware.is_dir():
raise RuntimeError('Vendor firmware not mounted')
Path('/sys/module/firmware_class/parameters/path').write_text(str(firmware))
boot = Path('/sys/kernel/boot_wlan/boot_wlan')
if boot.exists():
try:
boot.write_text('1\n')
except OSError as error:
if error.errno != errno.EALREADY:
raise
# pd-mapper publishes the Qualcomm WLAN protection domain. Its firmware
# can take a minute to start after the display HAL is ready.
for _ in range(180):
if Path('/sys/class/net/wlan0').exists():
return
time.sleep(.5)
wlan = Path('/dev/wlan')
if wlan.exists():
wlan.write_text('ON\n')
for _ in range(60):
if Path('/sys/class/net/wlan0').exists():
return
time.sleep(.5)
raise RuntimeError('Sargo WLAN driver did not expose wlan0')
if __name__ == '__main__':
main()

View file

@ -0,0 +1,186 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Small, allow-listed oFono broker used by the native Tundra session.
The Gaia process never receives access to the system D-Bus modem service. The
root service accepts one JSON request per Unix-socket connection and invokes
only fixed oFono methods with validated values. This keeps the diagnostic
image useful while avoiding a general purpose command bridge.
"""
import json
import importlib.util
import os
from pathlib import Path
import re
import socket
import subprocess
import sys
import time
SOCKET = Path('/run/tundra-radio/radio.sock')
MODEM = '/ril_0'
NUMBER = re.compile(r'^[0-9+#*(), .-]{3,32}$')
VOICE = re.compile(r'^/ril_0/voicecall[0-9]+$')
def bus(*args):
"""Call one fixed system-bus method and return busctl's JSON data."""
result = subprocess.run(
['/usr/bin/busctl', '--system', '--json=short', 'call', *args],
capture_output=True, text=True, timeout=15, check=False,
)
if result.returncode:
# Keep modem errors distinct from transport failures, without returning
# command lines, phone numbers or subscriber identifiers to Gaia.
detail = result.stderr.lower()
if 'not registered' in detail or 'notregistered' in detail:
raise RuntimeError('RADIO_NOT_REGISTERED')
if 'in progress' in detail or 'inprogress' in detail or 'busy' in detail:
raise RuntimeError('RADIO_BUSY')
if 'not available' in detail or 'notavailable' in detail:
raise RuntimeError('RADIO_NOT_READY')
raise RuntimeError('RADIO_REQUEST_FAILED')
# Successful void D-Bus methods (Hangup, Answer) produce no JSON in busctl.
if not result.stdout.strip():
return None
try:
payload = json.loads(result.stdout)
return payload.get('data')
except (ValueError, AttributeError):
raise RuntimeError('INVALID_OFONO_REPLY')
def request(value):
if not isinstance(value, dict) or value.get('version') != 1:
raise ValueError('INVALID_REQUEST')
operation = value.get('operation')
if operation in ('incomingMessages','ackMessages','messageState'):
spec = importlib.util.spec_from_file_location('inbox',Path(__file__).with_name('sms-inbox.py'))
inbox = importlib.util.module_from_spec(spec);spec.loader.exec_module(inbox)
if operation == 'messageState':
path = value.get('path', '')
if not isinstance(path,str) or not re.fullmatch(r'/ril_0/message[0-9a-f_]+',path):
raise ValueError('INVALID_SMS_PATH')
state = inbox.outgoing_state(path)
if state is None:
try:
props = bus('org.ofono',path,'org.ofono.Message','GetProperties')[0]
state = props.get('State',{}).get('data','unknown')
except RuntimeError: state = 'unknown'
return {'state':state if state in ('pending','sent','failed') else 'unknown'}
return inbox.pending() if operation == 'incomingMessages' else inbox.acknowledge(value.get('ids'))
if operation == 'calls':
data = bus('org.ofono', MODEM, 'org.ofono.VoiceCallManager', 'GetCalls')
calls = []
for path, properties in data[0]:
if not VOICE.fullmatch(path):
continue
def field(name):
item = properties.get(name, {})
return item.get('data') if isinstance(item, dict) else item
state = field('State')
if state not in ('dialing', 'alerting', 'active', 'held', 'incoming', 'waiting', 'disconnected'):
continue
calls.append({'path': path, 'state': state,
'number': str(field('LineIdentification') or '')[:80]})
return calls
if operation == 'status':
return {
'modem': bus('org.ofono', MODEM, 'org.ofono.Modem', 'GetProperties'),
'registration': bus('org.ofono', MODEM, 'org.ofono.NetworkRegistration', 'GetProperties'),
}
if operation == 'dial':
number = str(value.get('number', '')).strip()
if not NUMBER.fullmatch(number):
raise ValueError('INVALID_NUMBER')
path = bus('org.ofono', MODEM, 'org.ofono.VoiceCallManager',
'Dial', 'ss', number, 'default')
return {'path': path[0] if isinstance(path, list) and path else path,
'startedAt': time.time()}
if operation in ('hangup', 'answer'):
path = value.get('path', '')
if not isinstance(path, str) or not VOICE.fullmatch(path):
raise ValueError('INVALID_CALL')
bus('org.ofono', path, 'org.ofono.VoiceCall',
'Hangup' if operation == 'hangup' else 'Answer')
return {'path': path}
if operation == 'sms':
number = str(value.get('number', '')).strip()
body = value.get('body', '')
if not NUMBER.fullmatch(number) or not isinstance(body, str) or not body or len(body) > 1600:
raise ValueError('INVALID_SMS')
registration = bus('org.ofono', MODEM, 'org.ofono.NetworkRegistration', 'GetProperties')[0]
if registration.get('Status',{}).get('data') not in ('registered','roaming'):
raise RuntimeError('RADIO_NOT_REGISTERED')
result = bus('org.ofono', MODEM, 'org.ofono.MessageManager',
'SendMessage', 'ss', number, body)
return {'path': result[0] if isinstance(result, list) and result else result,
'state':'pending', 'number': number, 'bodyLength': len(body), 'submittedAt': time.time()}
raise ValueError('NOT_SUPPORTED')
def reply(stream, value):
stream.write((json.dumps(value, separators=(',', ':')) + '\n').encode())
stream.flush()
def serve():
SOCKET.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
try:
SOCKET.unlink()
except FileNotFoundError:
pass
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
server.bind(str(SOCKET))
os.chmod(SOCKET, 0o660)
try:
import grp
os.chown(SOCKET, 0, grp.getgrnam('droidian').gr_gid)
except (KeyError, OSError):
pass
server.listen(8)
while True:
conn, _ = server.accept()
with conn:
conn.settimeout(20)
line = b''
while not line.endswith(b'\n') and len(line) < 8192:
chunk = conn.recv(1024)
if not chunk:
break
line += chunk
try:
result = request(json.loads(line.decode()))
reply(conn.makefile('wb'), {'ok': True, 'result': result})
except (ValueError, RuntimeError, OSError, subprocess.TimeoutExpired) as error:
reply(conn.makefile('wb'), {'ok': False, 'error': str(error) or 'RADIO_ERROR'})
def client(envelope=False):
raw = sys.stdin.read(8192)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(20)
sock.connect(str(SOCKET))
sock.sendall(raw.encode() + (b'' if raw.endswith('\n') else b'\n'))
data = b''
while not data.endswith(b'\n') and len(data) < 65536:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
result = json.loads(data.decode())
if envelope:
print(json.dumps(result, separators=(',', ':')))
return
if not result.get('ok'):
raise RuntimeError(result.get('error', 'RADIO_ERROR'))
print(json.dumps(result.get('result'), separators=(',', ':')))
if __name__ == '__main__':
if '--server' in sys.argv:
serve()
elif '--client' in sys.argv or '--client-envelope' in sys.argv:
client(envelope='--client-envelope' in sys.argv)
else:
raise SystemExit('use --server or --client')

View file

@ -0,0 +1,86 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Mount the inventoried Sargo HAL read-only, with disposable init/data overlays."""
import hashlib
import json
import os
import re
from pathlib import Path
import subprocess
import hal_policy
from hal_policy import filter_rc, is_init_script
def run(*args):subprocess.run(args,check=True)
def directory(p):Path(p).mkdir(parents=True,exist_ok=True)
def overlay(lower,target,label):
upper='/run/tundra/'+label+'-upper';work='/run/tundra/'+label+'-work'
for p in (upper,work,target):directory(p)
run('mount','-t','overlay','overlay','-o',f'lowerdir={lower},upperdir={upper},workdir={work}',target)
def main():
if os.getuid()!=0:raise RuntimeError('HAL mounts require root')
# This service only runs in the temporary boot assembled by rescue-init.
if 'tundra.rescue=1' not in Path('/proc/cmdline').read_text():raise RuntimeError('Temporary boot flag missing')
directory('/var/lib/lxc')
if Path('/run/tundra/hal-ready').is_file():return
network = Path('/etc/tundra/network-enabled').is_file()
if network:
# Never let the experimental modem firmware write the installed NV.
# rmt_storage opens these paths as files; no block device is exposed.
destination = Path('/run/tundra/nv-block/bootdevice/by-name')
destination.mkdir(parents=True, exist_ok=True, mode=0o700)
partitions = {'modemst1': (46,2097152), 'modemst2': (47,2097152),
'fsg': (12,2097152), 'fsc': (42,131072)}
for name, (number, size) in partitions.items():
device = 'mmcblk0p'+str(number)
sectors = int(Path('/sys/class/block',device,'size').read_text())
if sectors*512 != size:
raise RuntimeError('Unexpected NV partition size: '+name)
with open('/dev/'+device,'rb',buffering=0) as source:
data = source.read(size)
if len(data) != size:raise RuntimeError('Incomplete NV read: '+name)
target = destination/name
target.write_bytes(data)
target.chmod(0o600)
hal_policy.SERVICES = hal_policy.SERVICES | hal_policy.NETWORK_SERVICES
if Path('/etc/tundra/hardware-enabled').is_file():
hal_policy.SERVICES = hal_policy.SERVICES | hal_policy.HARDWARE_SERVICES
for p in ('/run/tundra/android-original','/run/tundra/vendor-original','/dev/__properties__','/dev/socket','/mnt/vendor/persist'):
directory(p)
image=Path('/reference/android-rootfs.img')
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')
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')
if network:
# IPA firmware is not initialized in the minimal HAL. Enabling its WLAN
# offload dereferences a null ipahal context when the interface comes up.
config = Path('/android/vendor/firmware/wlan/qca_cld/WCNSS_qcom_cfg.ini')
text, count = re.subn(r'^gIPAConfig=.*$', 'gIPAConfig=0', config.read_text(), flags=re.M)
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')
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
text=p.read_text();filtered,record=filter_rc(text);record['sourceSha256']=hashlib.sha256(text.encode()).hexdigest()
p.write_text(filtered);report[str(p)]=record
(Path('/run/tundra')/'hal-policy-report.json').write_text(json.dumps(report,indent=2))
# The container cannot open block devices. Its data and metadata live in RAM.
for p in ('/android/data','/android/metadata'):
run('mount','-t','tmpfs','-o','mode=0771','tmpfs',p)
for p in ('/system','/vendor'):
path=Path(p)
if not path.is_symlink():
if path.exists():path.rmdir()
path.symlink_to('/android'+p)
Path('/run/tundra/hal-ready').touch()
if __name__=='__main__':main()

View file

@ -0,0 +1,79 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Keep oFono incoming messages until Gecko durably acknowledges them."""
import fcntl
import json
import os
from pathlib import Path
import time
import uuid
ROOT = Path('/var/lib/tundra/sms')
def transaction(change, root=ROOT, filename="inbox.json"):
if filename not in ("inbox.json", "outgoing.json"): raise ValueError("INVALID_SMS_STORE")
root.mkdir(parents=True, mode=0o700, exist_ok=True)
with (root/'lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
path = root/filename
rows = json.loads(path.read_text()) if path.exists() else []
result = change(rows)
temporary = path.with_suffix('.tmp')
with open(temporary, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as stream:
os.fchmod(stream.fileno(),0o600)
json.dump(rows,stream)
stream.flush(); os.fsync(stream.fileno())
temporary.replace(path)
return result
def pending():
return transaction(lambda rows: rows[:100])
def acknowledge(ids):
if not isinstance(ids,list) or len(ids)>100 or any(not isinstance(i,str) or len(i)>40 for i in ids):
raise ValueError('INVALID_SMS_IDS')
def remove(rows):
rows[:] = [row for row in rows if row['receipt'] not in ids]
transaction(remove)
return True
def outgoing_state(path):
rows = transaction(lambda rows: list(rows), filename='outgoing.json')
return next((row['state'] for row in reversed(rows) if row['path'] == path), None)
def record_outgoing(path, state):
if state not in ('sent','failed'): return
def update(rows):
rows[:] = [row for row in rows if row['path'] != path][-999:]
rows.append({'path':path,'state':state,'timestamp':round(time.time()*1000)})
transaction(update, filename='outgoing.json')
def main():
import dbus
from dbus.mainloop.glib import DBusGMainLoop
from gi.repository import GLib
os.umask(0o077)
DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
def incoming(body, properties):
row = {'receipt':str(uuid.uuid4()),'sender':str(properties.get('Sender','')),
'body':str(body),'timestamp':round(time.time()*1000)}
transaction(lambda rows:rows.append(row))
bus.add_signal_receiver(incoming,signal_name='IncomingMessage',
dbus_interface='org.ofono.MessageManager',bus_name='org.ofono',path='/ril_0')
def outgoing(name, value, path=None):
if name == 'State' and path and str(path).startswith('/ril_0/message'):
record_outgoing(str(path), str(value))
bus.add_signal_receiver(outgoing, signal_name='PropertyChanged',
dbus_interface='org.ofono.Message', bus_name='org.ofono', path_keyword='path')
GLib.MainLoop().run()
if __name__ == '__main__':
main()

View file

@ -0,0 +1,275 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Narrow native Wi-Fi bridge. Credentials travel over a private Unix socket."""
import argparse
import configparser
import json
import os
from pathlib import Path
import re
import socket
import struct
import subprocess
import sys
import uuid
SOCKET = '/run/tundra-wifi/control.sock'
MAX_REQUEST = 16384
DEVICE = 'wlan0'
PROFILE_DIR = Path('/etc/NetworkManager/system-connections')
class WifiError(Exception):
pass
def fields(line):
"""Decode nmcli's escaped colon-separated output, including SSIDs."""
result, value, escaped = [], '', False
for char in line:
if escaped:
value += char
escaped = False
elif char == '\\':
escaped = True
elif char == ':':
result.append(value)
value = ''
else:
value += char
if escaped:
value += '\\'
return result + [value]
def nm(*args, timeout=45):
try:
return subprocess.run(['/usr/bin/nmcli', '--wait', '30', *args],
capture_output=True, text=True, timeout=timeout,
env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LC_ALL': 'C'})
except (OSError, subprocess.TimeoutExpired):
raise WifiError('WIFI_UNAVAILABLE') from None
def networks(rescan=False):
result = nm('-t', '--escape', 'yes', '-f', 'DBUS-PATH,SSID,SIGNAL,SECURITY,IN-USE',
'device', 'wifi', 'list', 'ifname', DEVICE,
'--rescan', 'yes' if rescan else 'no')
if result.returncode:
raise WifiError('WIFI_UNAVAILABLE')
found = []
for line in result.stdout.splitlines():
parts = fields(line)
if len(parts) != 5:
continue
ident, ssid, signal, security, active = parts
if not re.fullmatch(r'/org/freedesktop/NetworkManager/AccessPoint/\d+', ident) or not ssid:
continue
if not signal.isdigit() or not 0 <= int(signal) <= 100:
continue
if not security or security == '--':
mode = 'open'
elif '802.1X' in security or 'EAP' in security:
mode = 'unsupported'
elif 'WPA3' in security and 'WPA2' not in security:
mode = 'sae'
elif 'WPA2' in security or 'WPA1' in security or security == 'WPA':
mode = 'wpa-psk'
else:
mode = 'unsupported'
found.append({'id': ident, 'ssid': ssid, 'signal': int(signal),
'security': mode, 'connected': active == '*'})
return sorted(found, key=lambda row: -row['signal'])[:128]
def saved_profiles(profile_dir):
result = []
for path in profile_dir.glob('vulpes-*.nmconnection'):
try:
if path.is_symlink():
continue
parser = configparser.ConfigParser(interpolation=None)
parser.read(path)
uid = str(uuid.UUID(parser['connection']['uuid']))
if path.name != 'vulpes-' + uid + '.nmconnection':
continue
ssid = bytes(int(b) for b in parser['wifi']['ssid'].split(';') if b).decode()
mode = parser.get('wifi-security', 'key-mgmt', fallback='open')
result.append({'id': uid, 'ssid': ssid, 'security': mode, 'known': True})
except (OSError, ValueError, KeyError, configparser.Error):
continue
return result
def private_write(target, text):
temporary = target.with_suffix('.tmp')
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, 'w') as stream:
os.fchmod(stream.fileno(), 0o600)
stream.write(text)
stream.flush()
os.fsync(stream.fileno())
temporary.replace(target)
def profile_text(network, password, uid):
ssid = network['ssid']
if not isinstance(ssid, str) or not 1 <= len(ssid.encode()) <= 32:
raise WifiError('WIFI_INVALID_REQUEST')
mode = network['security']
if mode not in ('open', 'wpa-psk', 'sae'):
raise WifiError('WIFI_UNSUPPORTED_SECURITY')
if not isinstance(password, str) or any(ord(c) < 32 for c in password):
raise WifiError('WIFI_INVALID_PASSWORD')
length = len(password.encode())
if mode == 'wpa-psk' and not (8 <= length <= 63 or re.fullmatch(r'[0-9a-fA-F]{64}', password)):
raise WifiError('WIFI_INVALID_PASSWORD')
if mode == 'sae' and not 1 <= length <= 63:
raise WifiError('WIFI_INVALID_PASSWORD')
text = (f'[connection]\nid=Vulpes Wi-Fi\nuuid={uid}\ntype=wifi\n'
'interface-name=wlan0\nautoconnect=true\n[wifi]\nmode=infrastructure\n'
'cloned-mac-address=permanent\nssid=' +
''.join(str(b) + ';' for b in ssid.encode()) +
'\n[ipv4]\nmethod=auto\n[ipv6]\nmethod=auto\n')
if mode != 'open':
escaped = password.replace('\\', '\\\\').replace(' ', '\\s')
text += f'[wifi-security]\nkey-mgmt={mode}\npsk={escaped}\n'
return text
def dispatch(request, profile_dir=PROFILE_DIR):
if not isinstance(request, dict) or set(request) - {'operation', 'id', 'password', 'enabled'}:
raise WifiError('WIFI_INVALID_REQUEST')
operation = request.get('operation')
saved = saved_profiles(profile_dir)
if operation == 'known':
return {'networks': saved}
if operation == 'forget':
network = next((row for row in saved if row['id'] == request.get('id')), None)
if network is None:
raise WifiError('WIFI_NETWORK_GONE')
uid = network['id']
if nm('connection', 'delete', 'uuid', uid).returncode:
raise WifiError('WIFI_FORGET_FAILED')
(profile_dir / ('vulpes-' + uid + '.nmconnection')).unlink(missing_ok=True)
return {'forgotten': True}
if operation in ('status', 'scan', 'setEnabled'):
if operation == 'setEnabled':
enabled = request.get('enabled')
if type(enabled) is not bool:
raise WifiError('WIFI_INVALID_REQUEST')
if nm('radio', 'wifi', 'on' if enabled else 'off').returncode:
raise WifiError('WIFI_UNAVAILABLE')
radio = nm('radio', 'wifi')
if radio.returncode:
raise WifiError('WIFI_UNAVAILABLE')
enabled = radio.stdout.strip() == 'enabled'
found = networks(rescan=True) if enabled and operation == 'scan' else []
for network in found:
stored = next((row for row in saved if row['ssid'] == network['ssid'] and row['security'] == network['security']), None)
network.update(known=stored is not None, savedId=stored['id'] if stored else None)
return {'enabled': enabled, 'networks': found}
if operation == 'disconnect':
if nm('device', 'disconnect', DEVICE).returncode:
raise WifiError('WIFI_DISCONNECT_FAILED')
return {'disconnected': True}
if operation != 'connect':
raise WifiError('WIFI_INVALID_REQUEST')
# Resolve an observed AP; clients cannot supply nmcli arguments or arbitrary SSIDs.
network = next((row for row in networks() if row['id'] == request.get('id')), None)
if network is None:
raise WifiError('WIFI_NETWORK_GONE')
stored = next((row for row in saved if row['ssid'] == network['ssid'] and row['security'] == network['security']), None)
uid = stored['id'] if stored else str(uuid.uuid4())
target = profile_dir / ('vulpes-' + uid + '.nmconnection')
previous = target.read_text() if stored else None
text = previous if stored and not request.get('password') else profile_text(network, request.get('password', ''), uid)
profile_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
target = profile_dir / ('vulpes-' + uid + '.nmconnection')
connected = False
try:
private_write(target, text)
if nm('connection', 'load', str(target)).returncode:
raise WifiError('WIFI_UNAVAILABLE')
if nm('connection', 'up', 'uuid', uid).returncode:
raise WifiError('WIFI_CONNECT_FAILED')
connected = True
return {'connected': True}
finally:
if not connected:
# Never include nmcli stderr: it may contain the SSID or credentials.
if previous is not None:
private_write(target, previous)
nm('connection', 'load', str(target))
else:
nm('connection', 'delete', 'uuid', uid)
target.unlink(missing_ok=True)
def receive(stream, limit):
data = bytearray()
while b'\n' not in data:
part = stream.recv(min(4096, limit + 1 - len(data)))
if not part:
raise WifiError('WIFI_INVALID_REQUEST')
data.extend(part)
if len(data) > limit:
raise WifiError('WIFI_INVALID_REQUEST')
if data.count(b'\n') != 1 or not data.endswith(b'\n'):
raise WifiError('WIFI_INVALID_REQUEST')
return json.loads(data)
def handle(stream):
stream.settimeout(5)
try:
_, uid, _ = struct.unpack('3i', stream.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
if uid not in (0, 32011):
raise WifiError('PERMISSION_DENIED')
result = {'result': dispatch(receive(stream, MAX_REQUEST))}
except WifiError as error:
result = {'error': str(error)}
except Exception:
result = {'error': 'WIFI_UNAVAILABLE'}
try:
stream.sendall(json.dumps(result).encode() + b'\n')
except OSError:
pass
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--client', action='store_true')
args = parser.parse_args()
if args.client:
try:
data = sys.stdin.buffer.read(MAX_REQUEST + 1)
if len(data) > MAX_REQUEST:
raise WifiError('WIFI_INVALID_REQUEST')
with socket.socket(socket.AF_UNIX) as stream:
stream.settimeout(90)
stream.connect(SOCKET)
stream.sendall(json.dumps(json.loads(data)).encode() + b'\n')
result = receive(stream, 65536)
except Exception:
result = {'error': 'WIFI_UNAVAILABLE'}
print(json.dumps(result))
return
if os.getuid() or 'tundra.rescue=1' not in Path('/proc/cmdline').read_text():
raise SystemExit('Temporary native boot required')
os.umask(0o077)
path = Path(SOCKET)
path.unlink(missing_ok=True)
with socket.socket(socket.AF_UNIX) as server:
server.bind(SOCKET)
os.chown(SOCKET, 0, 32011)
path.chmod(0o660)
server.listen(4)
while True:
stream, _ = server.accept()
with stream:
handle(stream)
if __name__ == '__main__':
main()

62
tundra/tests/fixtures/pulse14/cards.txt vendored Normal file
View file

@ -0,0 +1,62 @@
Card #0
Name: droid_card.primary
Driver: module-droid-card.c
Owner Module: 1
Properties:
droid.hw_module = "primary"
device.icon_name = "audio-card"
Profiles:
default: Default profile (sinks: 2, sources: 1, priority: 300, available: yes)
voicecall: Call mode (sinks: 0, sources: 0, priority: 0, available: yes)
voicecall-record: Call mode record (sinks: 0, sources: 0, priority: 0, available: yes)
communication: Communication mode (sinks: 0, sources: 0, priority: 0, available: yes)
ringtone: Ringtone mode (sinks: 0, sources: 0, priority: 0, available: yes)
off: Off (sinks: 0, sources: 0, priority: 0, available: yes)
Active Profile: default
Ports:
output-earpiece: Output to earpiece (type: Unknown, priority: 200, latency offset: 0 usec, available)
Part of profile(s): default
output-speaker: Output to speaker (type: Unknown, priority: 300, latency offset: 0 usec, available)
Part of profile(s): default
output-wired_headset: Output to wired headset (type: Unknown, priority: 100, latency offset: 0 usec, not available)
Part of profile(s): default
output-wired_headphone: Output to wired headphone (type: Unknown, priority: 100, latency offset: 0 usec, not available)
Part of profile(s): default
output-bluetooth_sco: Output to bluetooth sco (type: Unknown, priority: 100, latency offset: 0 usec, available)
Part of profile(s): default
output-sco_headset: Output to sco headset (type: Unknown, priority: 100, latency offset: 0 usec, available)
Part of profile(s): default
output-sco_carkit: Output to sco carkit (type: Unknown, priority: 100, latency offset: 0 usec, available)
Part of profile(s): default
output-a2dp: Output to a2dp (type: Unknown, priority: 100, latency offset: 0 usec, available)
Part of profile(s): default
output-a2dp_headphones: Output to a2dp headphones (type: Unknown, priority: 100, latency offset: 0 usec, available)
Part of profile(s): default
output-a2dp_speaker: Output to a2dp speaker (type: Unknown, priority: 100, latency offset: 0 usec, available)
Part of profile(s): default
output-usb_device: Output to usb device (type: Unknown, priority: 100, latency offset: 0 usec, available)
Part of profile(s): default
output-line: Output to line (type: Unknown, priority: 100, latency offset: 0 usec, available)
Part of profile(s): default
output-speaker_safe: Output to speaker safe (type: Unknown, priority: 200, latency offset: 0 usec, available)
Part of profile(s): default
output-speaker+wired_headphone: Output to speaker+wired headphone (type: Unknown, priority: 100, latency offset: 0 usec, not available)
Part of profile(s): default
output-parking: Parking port (type: Unknown, priority: 50, latency offset: 0 usec, available)
Part of profile(s): default
input-builtin_mic: Input from builtin mic (type: Unknown, priority: 200, latency offset: 0 usec, available)
Part of profile(s): default
input-bluetooth_sco_headset: Input from bluetooth sco headset (type: Unknown, priority: 200, latency offset: 0 usec, available)
Part of profile(s): default
input-wired_headset: Input from wired headset (type: Unknown, priority: 200, latency offset: 0 usec, not available)
Part of profile(s): default
input-voice_call: Input from voice call (type: Unknown, priority: 200, latency offset: 0 usec, available)
Part of profile(s): default
input-back_mic: Input from back mic (type: Unknown, priority: 200, latency offset: 0 usec, available)
Part of profile(s): default
input-usb_device: Input from usb device (type: Unknown, priority: 200, latency offset: 0 usec, available)
Part of profile(s): default
input-default: Input from default (type: Unknown, priority: 200, latency offset: 0 usec, available)
Part of profile(s): default
input-parking: Parking port (type: Unknown, priority: 50, latency offset: 0 usec, available)
Part of profile(s): default

87
tundra/tests/fixtures/pulse14/sinks.txt vendored Normal file
View file

@ -0,0 +1,87 @@
Sink #0
State: SUSPENDED
Name: sink.primary_output
Description: Droid sink primary_output
Driver: module-droid-card.c
Sample Specification: s16le 2ch 48000Hz
Channel Map: front-left,front-right
Owner Module: 1
Mute: no
Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
balance 0.00
Base Volume: 65536 / 100% / 0.00 dB
Monitor Source: sink.primary_output.monitor
Latency: 0 usec, configured 0 usec
Flags: HARDWARE DECIBEL_VOLUME LATENCY
Properties:
device.description = "Droid sink primary_output"
device.class = "sound"
device.api = "droid-hal"
droid.flags = "AUDIO_OUTPUT_FLAG_PRIMARY|AUDIO_OUTPUT_FLAG_FAST"
droid.hw_module = "primary"
device.icon_name = "audio-card"
droid.output.primary = "true"
droid.output.low_latency = "true"
droid.output.media_latency = "false"
Ports:
output-earpiece: Output to earpiece (type: Unknown, priority: 200, available)
output-speaker: Output to speaker (type: Unknown, priority: 300, available)
output-wired_headset: Output to wired headset (type: Unknown, priority: 100, not available)
output-wired_headphone: Output to wired headphone (type: Unknown, priority: 100, not available)
output-bluetooth_sco: Output to bluetooth sco (type: Unknown, priority: 100, available)
output-sco_headset: Output to sco headset (type: Unknown, priority: 100, available)
output-sco_carkit: Output to sco carkit (type: Unknown, priority: 100, available)
output-a2dp: Output to a2dp (type: Unknown, priority: 100, available)
output-a2dp_headphones: Output to a2dp headphones (type: Unknown, priority: 100, available)
output-a2dp_speaker: Output to a2dp speaker (type: Unknown, priority: 100, available)
output-usb_device: Output to usb device (type: Unknown, priority: 100, available)
output-line: Output to line (type: Unknown, priority: 100, available)
output-speaker_safe: Output to speaker safe (type: Unknown, priority: 200, available)
output-speaker+wired_headphone: Output to speaker+wired headphone (type: Unknown, priority: 100, not available)
output-parking: Parking port (type: Unknown, priority: 50, available)
Active Port: output-speaker
Formats:
pcm
Sink #1
State: SUSPENDED
Name: sink.deep_buffer
Description: Droid sink deep_buffer
Driver: module-droid-card.c
Sample Specification: s16le 2ch 48000Hz
Channel Map: front-left,front-right
Owner Module: 1
Mute: no
Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
balance 0.00
Base Volume: 65536 / 100% / 0.00 dB
Monitor Source: sink.deep_buffer.monitor
Latency: 0 usec, configured 0 usec
Flags: HARDWARE DECIBEL_VOLUME LATENCY
Properties:
device.description = "Droid sink deep_buffer"
device.class = "sound"
device.api = "droid-hal"
droid.flags = "AUDIO_OUTPUT_FLAG_DEEP_BUFFER"
droid.hw_module = "primary"
device.icon_name = "audio-card"
droid.output.media_latency = "true"
Ports:
output-earpiece: Output to earpiece (type: Unknown, priority: 200, available)
output-speaker: Output to speaker (type: Unknown, priority: 300, available)
output-wired_headset: Output to wired headset (type: Unknown, priority: 100, not available)
output-wired_headphone: Output to wired headphone (type: Unknown, priority: 100, not available)
output-bluetooth_sco: Output to bluetooth sco (type: Unknown, priority: 100, available)
output-sco_headset: Output to sco headset (type: Unknown, priority: 100, available)
output-sco_carkit: Output to sco carkit (type: Unknown, priority: 100, available)
output-a2dp: Output to a2dp (type: Unknown, priority: 100, available)
output-a2dp_headphones: Output to a2dp headphones (type: Unknown, priority: 100, available)
output-a2dp_speaker: Output to a2dp speaker (type: Unknown, priority: 100, available)
output-usb_device: Output to usb device (type: Unknown, priority: 100, available)
output-line: Output to line (type: Unknown, priority: 100, available)
output-speaker_safe: Output to speaker safe (type: Unknown, priority: 200, available)
output-speaker+wired_headphone: Output to speaker+wired headphone (type: Unknown, priority: 100, not available)
output-parking: Parking port (type: Unknown, priority: 50, available)
Active Port: output-speaker
Formats:
pcm

View file

@ -0,0 +1,81 @@
Source #0
State: SUSPENDED
Name: sink.primary_output.monitor
Description: Monitor of Droid sink primary_output
Driver: module-droid-card.c
Sample Specification: s16le 2ch 48000Hz
Channel Map: front-left,front-right
Owner Module: 1
Mute: no
Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
balance 0.00
Base Volume: 65536 / 100% / 0.00 dB
Monitor of Sink: sink.primary_output
Latency: 0 usec, configured 0 usec
Flags: DECIBEL_VOLUME LATENCY
Properties:
device.description = "Monitor of Droid sink primary_output"
device.class = "monitor"
droid.hw_module = "primary"
device.icon_name = "audio-card"
Formats:
pcm
Source #1
State: SUSPENDED
Name: sink.deep_buffer.monitor
Description: Monitor of Droid sink deep_buffer
Driver: module-droid-card.c
Sample Specification: s16le 2ch 48000Hz
Channel Map: front-left,front-right
Owner Module: 1
Mute: no
Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
balance 0.00
Base Volume: 65536 / 100% / 0.00 dB
Monitor of Sink: sink.deep_buffer
Latency: 0 usec, configured 0 usec
Flags: DECIBEL_VOLUME LATENCY
Properties:
device.description = "Monitor of Droid sink deep_buffer"
device.class = "monitor"
droid.hw_module = "primary"
device.icon_name = "audio-card"
Formats:
pcm
Source #2
State: SUSPENDED
Name: source.droid
Description: Droid source droid
Driver: module-droid-card.c
Sample Specification: s16le 2ch 48000Hz
Channel Map: front-left,front-right
Owner Module: 1
Mute: no
Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
balance 0.00
Base Volume: 65536 / 100% / 0.00 dB
Monitor of Sink: n/a
Latency: 0 usec, configured 0 usec
Flags: HARDWARE HW_MUTE_CTRL DECIBEL_VOLUME
Properties:
device.description = "Droid source droid"
device.class = "sound"
device.api = "droid-hal"
droid.input.external = "true"
droid.input.builtin = "true"
droid.hw_module = "primary"
device.icon_name = "audio-card"
Ports:
input-builtin_mic: Input from builtin mic (type: Unknown, priority: 200, available)
input-bluetooth_sco_headset: Input from bluetooth sco headset (type: Unknown, priority: 200, available)
input-wired_headset: Input from wired headset (type: Unknown, priority: 200, not available)
input-voice_call: Input from voice call (type: Unknown, priority: 200, available)
input-back_mic: Input from back mic (type: Unknown, priority: 200, available)
input-usb_device: Input from usb device (type: Unknown, priority: 200, available)
input-default: Input from default (type: Unknown, priority: 200, available)
input-parking: Parking port (type: Unknown, priority: 50, available)
Active Port: input-builtin_mic
Formats:
pcm

View file

@ -0,0 +1,46 @@
# SPDX-License-Identifier: MPL-2.0
import hashlib
import importlib.util
import io
from pathlib import Path
import unittest
from unittest.mock import patch
SOURCE = Path(__file__).resolve().parents[1] / 'droidian/backup-boot.py'
spec = importlib.util.spec_from_file_location('backup_boot', SOURCE)
backup = importlib.util.module_from_spec(spec)
spec.loader.exec_module(backup)
class BootBackupTests(unittest.TestCase):
def test_copy_is_exact_even_with_more_input(self):
data = b'sargo boot' * 200000
source, destination = io.BytesIO(data + b'next partition'), io.BytesIO()
self.assertEqual(backup.copy_exact(source, destination, len(data)),
hashlib.sha256(data).hexdigest())
self.assertEqual(destination.getvalue(), data)
self.assertEqual(source.read(), b'next partition')
def test_short_partition_read_is_not_reported_as_success(self):
with self.assertRaisesRegex(RuntimeError, 'before its declared size'):
backup.copy_exact(io.BytesIO(b'incomplete'), io.BytesIO(), 1024)
def test_no_privilege_means_no_output_or_device_access(self):
with patch.object(backup.os, 'geteuid', return_value=32011), \
patch.object(backup.tempfile, 'mkdtemp') as mkdir, \
patch.object(backup.os, 'open') as open_device:
with self.assertRaisesRegex(RuntimeError, 'require sudo'):
backup.main()
mkdir.assert_not_called()
open_device.assert_not_called()
def test_other_device_is_rejected_before_output(self):
with patch.object(backup.os, 'geteuid', return_value=0), \
patch.object(backup.pwd, 'getpwnam'), \
patch.object(backup.Path, 'exists', return_value=True), \
patch.object(backup.Path, 'read_bytes', return_value=b'google,bonito\0'), \
patch.object(backup.subprocess, 'check_output', return_value='bonito\n'), \
patch.object(backup.tempfile, 'mkdtemp') as mkdir:
with self.assertRaisesRegex(RuntimeError, 'inventoried Sargo'):
backup.main()
mkdir.assert_not_called()

View file

@ -0,0 +1,36 @@
# SPDX-License-Identifier: MPL-2.0
import importlib.util
from pathlib import Path
import sys
import tempfile
import unittest
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'tools'))
spec=importlib.util.spec_from_file_location('rescue_test',ROOT/'tools/test-rescue-boot.py')
rescue=importlib.util.module_from_spec(spec);spec.loader.exec_module(rescue)
class SourceSnapshotTests(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
self.folder=Path(self.temp.name)
self.source=self.folder/'sources/tundra/boot/init.sh'
self.source.parent.mkdir(parents=True)
self.source.write_text('immutable boot source')
self.build={'sourceSnapshot':True,'sources':{'boot/init.sh':rescue.digest(self.source)}}
def test_snapshot_does_not_depend_on_current_checkout(self):
rescue.verify_sources(self.folder,self.build)
def test_tampering_with_saved_source_is_rejected(self):
self.source.write_text('changed')
with self.assertRaisesRegex(ValueError,'source changed'):
rescue.verify_sources(self.folder,self.build)
def test_path_cannot_escape_snapshot(self):
self.build['sources']={'../../outside':'hash'}
with self.assertRaises(ValueError):rescue.verify_sources(self.folder,self.build)
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,34 @@
import importlib.util
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
s=importlib.util.spec_from_file_location('buttons',Path(__file__).resolve().parents[1]/'runtime/buttons.py')
b=importlib.util.module_from_spec(s);s.loader.exec_module(b)
class Buttons(unittest.TestCase):
def test_only_physical_control_edges(self):
self.assertEqual(b.key_event(1,116,1),'sleep-button-press')
self.assertEqual(b.key_event(1,115,0),'volume-up-button-release')
for event in [(3,116,1),(1,30,1),(1,116,2)]:self.assertIsNone(b.key_event(*event))
def test_backlight_validation_and_wake(self):
with tempfile.TemporaryDirectory() as d,patch.object(b,'BACKLIGHT',Path(d)):
(Path(d)/'max_brightness').write_text('1023')
b.control({'operation':'screen','enabled':False,'level':.5})
self.assertEqual((Path(d)/'brightness').read_text(),'0')
b.control({'operation':'screen','enabled':True,'level':.5})
self.assertEqual((Path(d)/'brightness').read_text(),'512')
for p in [{'operation':'screen','enabled':True,'level':-1},{'operation':'volume','level':float('nan')},{'operation':'exec','command':'true'}]:
with self.assertRaises(ValueError):b.control(p)
def test_vibration_pattern_progresses_without_blocking_and_can_be_cancelled(self):
with tempfile.TemporaryDirectory() as d,patch.object(b,'VIBRATOR',Path(d)),patch.object(b.time,'monotonic',return_value=10):
v=b.Vibration();v.start([200,100,300])
self.assertEqual((Path(d)/'brightness').read_text(),'255')
self.assertEqual(v.deadline,10.2)
v.advance(10.1);self.assertEqual((Path(d)/'brightness').read_text(),'255')
v.advance(10.2);self.assertEqual((Path(d)/'brightness').read_text(),'0')
v.advance(10.31);self.assertEqual((Path(d)/'brightness').read_text(),'255')
v.advance(10.62);self.assertEqual((Path(d)/'brightness').read_text(),'0')
v.start([200]);v.start([]);self.assertIsNone(v.deadline);self.assertEqual((Path(d)/'brightness').read_text(),'0')
for pattern in [None,[-1],[True],[10001],[5000,5001],[1]*33]:
with self.assertRaises(ValueError):v.start(pattern)

View file

@ -0,0 +1,62 @@
import importlib.util,json,unittest
from pathlib import Path
spec=importlib.util.spec_from_file_location('audio',Path(__file__).resolve().parents[1]/'runtime/call-audio.py')
audio=importlib.util.module_from_spec(spec);spec.loader.exec_module(audio)
class AudioTests(unittest.TestCase):
def rig(self):
commands=[]
devices={'cards':[{'index':0,'owner_module':1,'name':'droid_card.primary','profiles':{'default':{},'voicecall':{}},'active_profile':'default'}],
'sinks':[{'owner_module':1,'name':'sink.primary_output','ports':[{'name':'output-earpiece'},{'name':'output-parking'},{'name':'output-speaker'}],'active_port':'output-speaker','mute':True}],
'sources':[{'name':'source.droid','mute':True}],
'sink-inputs':[{'index':9,'properties':{'media.role':'phone'}}]}
def command(*args):
commands.append(args)
return ''
route=audio.CallAudio(command)
route.devices=lambda kind:devices[kind]
return route,commands,devices
def test_actual_pulse14_listing_and_routing(self):
fixture=Path(__file__).parent/'fixtures/pulse14'
commands=[]
def command(*args):
commands.append(args)
if args[0]=='list':
return (fixture/(args[1]+'.txt')).read_text() if args[1]!='sink-inputs' else ''
return ''
route=audio.CallAudio(command)
self.assertEqual(len(route.devices('cards')),1)
self.assertEqual(route.devices('cards')[0]['owner_module'],1)
self.assertIn('output-earpiece',route.devices('sinks')[0]['ports'])
route.enter();self.assertTrue(route.active)
route.leave();self.assertIsNone(route.saved)
self.assertIn(('set-sink-port','sink.primary_output','output-earpiece'),commands)
def test_active_call_routes_earpiece_then_restores(self):
route,c,_=self.rig()
route.update([{'state':'active'}]);n=len(c)
route.update([{'state':'active'}]);self.assertEqual(len(c),n)
self.assertIn(('set-card-profile','droid_card.primary','voicecall'),c)
self.assertIn(('set-sink-port','sink.primary_output','output-earpiece'),c)
self.assertNotIn(('set-sink-port','sink.primary_output','output-speaker'),c)
self.assertIn(('set-source-mute','source.droid','0'),c)
route.update([])
self.assertIsNone(route.saved)
self.assertIn(('set-card-profile','droid_card.primary','default'),c)
self.assertIn(('set-source-mute','source.droid','1'),c)
def test_controls_route_speaker_mute_and_phone_stream(self):
route,c,_=self.rig();route.enter()
result=route.control({'operation':'set','speaker':True,'muted':True,'volume':0.8})
self.assertTrue(result['speaker']);self.assertTrue(result['muted'])
self.assertIn(('set-sink-port','sink.primary_output','output-speaker'),c)
self.assertIn(('set-source-mute','source.droid','1'),c)
self.assertIn(('set-sink-input-volume','9','80%'),c)
route.leave();self.assertFalse(route.muted);self.assertFalse(route.speaker)
with self.assertRaisesRegex(ValueError,'NOT_ACTIVE'):route.control({'operation':'set','muted':True})
def test_ringing_alone_does_not_open_microphone(self):
route,c,_=self.rig();route.update([{'state':'incoming'}]);self.assertEqual(c,[])
def test_missing_earpiece_never_falls_back_to_speaker(self):
route,c,d=self.rig();d['sinks'][0]['ports']=[{'name':'output-speaker'}]
with self.assertRaisesRegex(RuntimeError,'EARPIECE'):route.update([{'state':'active'}])
self.assertFalse(any(x[0].startswith('set-') for x in c))
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,38 @@
"""Resolve generic text fonts against the fonts shipped in the native image."""
import importlib.util
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from xml.sax.saxutils import escape
ROOT = Path(__file__).resolve().parents[1]
@unittest.skipUnless(shutil.which('fc-match'), 'Fontconfig is needed for font resolution')
class NativeFontsTest(unittest.TestCase):
def test_generic_and_explicit_names_resolve_to_shipped_fira(self):
spec = importlib.util.spec_from_file_location('native_fonts', ROOT/'runtime/prepare-native.py')
native = importlib.util.module_from_spec(spec)
spec.loader.exec_module(native)
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root/'opt/vulpes/trial').mkdir(parents=True)
(root/'opt/vulpes/trial/session.py').touch()
native.prepare(root, network=False, hardware=False)
fonts = root/'usr/share/fonts/opentype/vulpes'
config = root/'fonts.conf'
config.write_text('<fontconfig><dir>'+escape(str(fonts))+'</dir><cachedir>'+
escape(str(root/'cache'))+'</cachedir><include>'+
escape(str(root/'etc/fonts/conf.d/00-vulpes-sans.conf'))+
'</include></fontconfig>')
for requested in ('sans-serif', 'FiraSans', 'Fira Sans'):
result = subprocess.check_output(
['fc-match', '-f', '%{family}\n%{file}\n', requested], text=True,
env={**os.environ, 'FONTCONFIG_FILE':str(config), 'LC_ALL':'C'})
family, filename = result.strip().splitlines()
self.assertIn('Fira Sans', family)
self.assertEqual(Path(filename).parent, fonts)
self.assertEqual(Path(filename).name, 'FiraSans-Regular.otf')

View file

@ -0,0 +1,58 @@
# SPDX-License-Identifier: MPL-2.0
import importlib.util
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('hardware', ROOT/'runtime/hardware-status.py')
hardware = importlib.util.module_from_spec(spec)
spec.loader.exec_module(hardware)
spec = importlib.util.spec_from_file_location('native', ROOT/'runtime/prepare-native.py')
native = importlib.util.module_from_spec(spec)
spec.loader.exec_module(native)
class HardwareTests(unittest.TestCase):
def test_battery_absent_invalid_and_real_charge(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
self.assertIsNone(hardware.battery_state(root))
(root/'capacity').write_text('72')
(root/'status').write_text('Discharging')
self.assertEqual(hardware.battery_state(root),
{'level': .72, 'charging': False, 'status': 'Discharging'})
(root/'status').write_text('Full')
self.assertTrue(hardware.battery_state(root)['charging'])
(root/'capacity').write_text('101')
self.assertIsNone(hardware.battery_state(root))
def test_wifi_reports_missing_and_real_device_states_without_identifiers(self):
for code, state, available in [(20,'unavailable',False), (30,'disconnected',True),
(100,'connected',True), (120,'failed',True)]:
with patch.object(hardware.subprocess, 'run', return_value=SimpleNamespace(returncode=0, stdout=f'{code} (label)')) as run:
self.assertEqual(hardware.wifi_state(), {'available': available, 'state': state})
self.assertEqual(run.call_args.args[0][2], 'GENERAL.STATE')
with patch.object(hardware.subprocess, 'run', side_effect=FileNotFoundError):
self.assertFalse(hardware.wifi_state()['available'])
def test_native_network_configuration_preserves_rescue_and_has_no_connections(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
(root/'opt/vulpes/trial').mkdir(parents=True)
(root/'opt/vulpes/trial/session.py').touch()
(root/'usr/sbin').mkdir(parents=True)
(root/'usr/sbin/NetworkManager').touch()
native.prepare(root)
config = (root/'etc/NetworkManager/NetworkManager.conf').read_text()
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())
self.assertIn('VULPES_TUNDRA=1', (root/'etc/systemd/system/tundra-display.service').read_text())
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,29 @@
# SPDX-License-Identifier: MPL-2.0
"""The RAM image must retain PT_INTERP even on a translated build host."""
import os
from pathlib import Path
import stat
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'tools'))
from ramdisk_reference import select_helpers
class HelperDependenciesTests(unittest.TestCase):
def test_french_host_keeps_dynamic_interpreter_and_library(self):
entries = {name: {'mode':stat.S_IFREG|0o755, 'data':name.encode()}
for name in ('bin/busybox','sbin/dropbear','sbin/dmsetup',
'lib/aarch64-linux-gnu/libnss_files.so.2',
'lib/aarch64-linux-gnu/libc.so.6',
'lib/ld-linux-aarch64.so.1')}
def readelf(command, **kwargs):
if Path(command[-1]).read_bytes() != b'sbin/dropbear': return ''
label = 'Requesting program interpreter' if kwargs.get('env',{}).get('LC_ALL') == 'C' else 'Interpréteur de programme requis'
return f'[{label}: /lib/ld-linux-aarch64.so.1]\n(NEEDED) [libc.so.6]\n'
with tempfile.TemporaryDirectory() as temp, patch.dict(os.environ, {'LC_ALL':'fr_FR.UTF-8'}), patch('ramdisk_reference.subprocess.check_output', side_effect=readelf):
helpers=select_helpers(entries,Path(temp))
self.assertIn('lib/ld-linux-aarch64.so.1', helpers)
self.assertIn('lib/aarch64-linux-gnu/libc.so.6', helpers)
if __name__=='__main__': unittest.main()

View file

@ -0,0 +1,65 @@
# SPDX-License-Identifier: MPL-2.0
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'tools'))
spec=importlib.util.spec_from_file_location('native_launch',ROOT/'tools/run-sargo-native.py')
launch=importlib.util.module_from_spec(spec);spec.loader.exec_module(launch)
class QualifiedLaunchTests(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
self.root=Path(self.temp.name);self.folder=self.root/'out/build'
self.folder.mkdir(parents=True)
(self.folder/'proof.json').write_text('{"passed":true}')
(self.folder/'build.json').write_text(json.dumps({'imageSha256':'image-hash'}))
self.report={'passed':True,'flashReady':False,'imageSha256':'image-hash',
'evidenceSha256':{'proof.json':launch.digest(self.folder/'proof.json')}}
self.write_report()
self.root_patch=patch.object(launch,'ROOT',self.root);self.root_patch.start();self.addCleanup(self.root_patch.stop)
def write_report(self):
report=self.folder/'qualification.json';report.write_text(json.dumps(self.report))
(self.root/'out/sargo-native-qualified.json').write_text(json.dumps({
'directory':'out/build','qualificationSha256':launch.digest(report)}))
def test_local_verification_does_not_request_boot(self):
with patch.object(launch.subprocess,'run') as run:
self.assertEqual(launch.qualified_build(),self.folder)
self.assertNotIn('--boot',run.call_args.args[0])
def test_modified_evidence_stops_before_device_tool(self):
(self.folder/'proof.json').write_text('changed')
with patch.object(launch.subprocess,'run') as run:
with self.assertRaisesRegex(ValueError,'evidence changed'):launch.qualified_build()
run.assert_not_called()
def test_failed_qualification_stops_before_device_tool(self):
self.report['passed']=False;self.write_report()
with patch.object(launch.subprocess,'run') as run:
with self.assertRaisesRegex(ValueError,'qualified native'):launch.qualified_build()
run.assert_not_called()
def test_interactive_diagnostics_cannot_be_the_normal_launcher(self):
(self.folder/'build.json').write_text(json.dumps({
'imageSha256':'image-hash', 'interactiveDiagnostics':True}))
with patch.object(launch.subprocess,'run') as run:
with self.assertRaisesRegex(ValueError,'Interactive diagnostic'):
launch.qualified_build()
run.assert_not_called()
def test_pointer_cannot_escape_output_directory(self):
(self.root/'out/sargo-native-qualified.json').write_text(json.dumps({'directory':'../outside'}))
with patch.object(launch.subprocess,'run') as run:
with self.assertRaises(ValueError):launch.qualified_build()
run.assert_not_called()
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,81 @@
# SPDX-License-Identifier: MPL-2.0
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'tools'))
spec=importlib.util.spec_from_file_location('install_sargo',ROOT/'tools/install-sargo.py')
install=importlib.util.module_from_spec(spec);spec.loader.exec_module(install)
class InstallTests(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
self.folder=Path(self.temp.name)
self.image=self.folder/'boot.img';self.image.write_bytes(b'boot')
self.proof=self.folder/'proof.json';self.proof.write_text('{}')
self.build={'image':'boot.img','imageSha256':install.digest(self.image),
'persistentState':{'uuid':'state'},'watchdogSeconds':None,
'serialSha256':'serial','systemImage':{'imageSha256':'system'}}
self.qualification={'passed':True,'privateInstallReady':True,
'imageSha256':self.build['imageSha256'],
'evidenceSha256':{'proof.json':install.digest(self.proof)},
'persistence':{'passed':True,'distinctBoots':True,'sameGaiaSetting':True,'restored':True}}
self.save()
def save(self):
(self.folder/'build.json').write_text(json.dumps(self.build))
(self.folder/'qualification.json').write_text(json.dumps(self.qualification))
def validate(self):
with patch.object(install,'load',return_value=SimpleNamespace(verify_sources=lambda *args:None)):
return install.checked_build(self.folder)
def test_qualified_image(self):
self.assertEqual(self.validate()[2],self.image)
def test_mutated_image_or_proof_rejected(self):
self.image.write_bytes(b'bad')
with self.assertRaisesRegex(ValueError,'provenance'):self.validate()
self.image.write_bytes(b'boot');self.proof.write_text('bad')
with self.assertRaisesRegex(ValueError,'evidence'):self.validate()
def test_diagnostic_and_timed_images_rejected(self):
self.build['interactiveDiagnostics']=True;self.save()
with self.assertRaises(ValueError):self.validate()
self.build['interactiveDiagnostics']=False;self.build['watchdogSeconds']=600;self.save()
with self.assertRaises(ValueError):self.validate()
def test_fake_persistence_rejected(self):
self.qualification['persistence']['distinctBoots']=False;self.save()
with self.assertRaisesRegex(ValueError,'round trip'):self.validate()
def test_only_boot_a_written(self):
with patch.object(install.subprocess,'run') as run:
install.flash_boot_a('device',self.image)
self.assertEqual(run.call_args.args[0],['fastboot','-s','device','flash','boot_a',str(self.image)])
def test_default_only_reviews(self):
boot=SimpleNamespace(checked_plan=lambda:({'serialSha256':'serial'},None))
with patch.object(sys,'argv',['install','--build',str(self.folder)]), \
patch.object(install,'checked_build',return_value=(self.build,self.qualification,self.image)), \
patch.object(install,'load',return_value=boot),patch.object(install,'flash_boot_a') as flash:
install.main();flash.assert_not_called()
def test_restore_does_not_require_working_candidate(self):
backup=self.folder/'boot_a.img';backup.write_bytes(b'original')
boot=SimpleNamespace(checked_plan=lambda:({'serialSha256':'serial','backupDirectory':str(self.folder)},None),
find_fastboot=lambda *args:'device',check_device=lambda *args:{'product':'sargo'})
with patch.object(sys,'argv',['install','--build',str(self.folder),'--restore']), \
patch.object(install,'load',return_value=boot), \
patch.object(install,'checked_build',side_effect=AssertionError('Candidate cannot gate recovery')), \
patch.object(install,'flash_boot_a') as flash:
install.main();flash.assert_called_once_with('device',backup)
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,52 @@
# SPDX-License-Identifier: MPL-2.0
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools'))
import persistent_state as state
class PersistentStateTests(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
self.root=Path(self.temp.name)
self.identifier='2a29bca9-5fcb-48a1-a287-dc975f56b353'
self.image=self.root/(self.identifier+'.ext4')
with self.image.open('wb') as output:output.truncate(512*1024**2)
self.value={'schema':1,'device':'sargo','uuid':self.identifier,
'image':self.image.name,'bytes':512*1024**2,
'imageSha256':'a'*64,'systemImageSha256':'b'*64,
'filesystemCheckPassed':True}
self.path=self.root/'state.json'
def validate(self):
self.path.write_text(json.dumps(self.value))
with patch.object(state,'digest',return_value='a'*64):
return state.validate_manifest(self.path)
def test_valid_prepared_state(self):
self.assertEqual(self.validate()['uuid'],self.identifier)
def test_path_escape_and_shell_input_rejected(self):
for key,value in [('image','../../root.img'),('uuid','$(command)'),('systemImageSha256','; reboot')]:
old=self.value[key];self.value[key]=value
with self.assertRaises(ValueError):self.validate()
self.value[key]=old
def test_size_change_rejected(self):
self.image.write_bytes(b'changed')
with self.assertRaisesRegex(ValueError,'changed'):self.validate()
def test_symlink_rejected(self):
target=self.root/'other';self.image.rename(target);self.image.symlink_to(target)
with self.assertRaisesRegex(ValueError,'changed'):self.validate()
def test_missing_fs_check_rejected(self):
self.value['filesystemCheckPassed']=False
with self.assertRaisesRegex(ValueError,'verification'):self.validate()
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,90 @@
import importlib.util
from pathlib import Path
import unittest
from types import SimpleNamespace
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('radio_control', ROOT / 'runtime/radio-control.py')
radio = importlib.util.module_from_spec(spec)
spec.loader.exec_module(radio)
class RadioControlTests(unittest.TestCase):
def test_void_dbus_success_is_not_an_invalid_reply(self):
with patch.object(radio.subprocess, 'run', return_value=SimpleNamespace(
returncode=0, stdout='', stderr='')):
self.assertIsNone(radio.bus('org.ofono', '/ril_0/voicecall01', 'org.ofono.VoiceCall', 'Hangup'))
def test_calls_returns_only_public_call_fields(self):
payload = [[['/ril_0/voicecall01', {
'State': {'type': 's', 'data': 'active'},
'LineIdentification': {'type': 's', 'data': '+12025550123'},
'PrivateModemData': {'type': 's', 'data': 'secret'},
}]]]
with patch.object(radio, 'bus', return_value=payload):
self.assertEqual(radio.request({'version': 1, 'operation': 'calls'}), [
{'path': '/ril_0/voicecall01', 'state': 'active', 'number': '+12025550123'}])
with patch.object(radio, 'bus', return_value=[[]]):
self.assertEqual(radio.request({'version': 1, 'operation': 'calls'}), [])
def test_answer_and_hangup_target_only_the_selected_call(self):
for operation, method in [('answer', 'Answer'), ('hangup', 'Hangup')]:
with patch.object(radio, 'bus', return_value=[]) as bus:
radio.request({'version': 1, 'operation': operation, 'path': '/ril_0/voicecall01'})
bus.assert_called_once_with('org.ofono', '/ril_0/voicecall01', 'org.ofono.VoiceCall', method)
with self.assertRaises(ValueError):
radio.request({'version': 1, 'operation': operation, 'path': '/private'})
def test_modem_rejections_are_not_reported_as_transport_failures(self):
for detail, expected in [
('Call failed: Not registered', 'RADIO_NOT_REGISTERED'),
('org.ofono.Error.InProgress', 'RADIO_BUSY'),
('Call failed: Not available', 'RADIO_NOT_READY'),
('private modem response', 'RADIO_REQUEST_FAILED'),
]:
with self.subTest(detail=detail), patch.object(radio.subprocess, 'run',
return_value=SimpleNamespace(returncode=1, stderr=detail)):
with self.assertRaisesRegex(RuntimeError, '^'+expected+'$'):
radio.bus('org.ofono', '/ril_0', 'org.ofono.Modem', 'GetProperties')
def test_dial_is_allowlisted_and_validated(self):
with patch.object(radio, 'bus', return_value=['/ril_0/voicecall01']) as bus:
result = radio.request({'version': 1, 'operation': 'dial', 'number': '+12025550123'})
self.assertEqual(result['path'], '/ril_0/voicecall01')
bus.assert_called_once_with(
'org.ofono', '/ril_0', 'org.ofono.VoiceCallManager', 'Dial', 'ss', '+12025550123', 'default'
)
def test_sms_does_not_accept_arbitrary_dbus_values(self):
with self.assertRaises(ValueError):
radio.request({'version': 1, 'operation': 'sms', 'number': 'x', 'body': 'hello'})
with self.assertRaises(ValueError):
radio.request({'version': 1, 'operation': 'sms', 'number': '+12025550123', 'body': ''})
def test_sms_refuses_unregistered_network_before_submission(self):
for state in ('searching', 'denied', 'unregistered'):
with self.subTest(state=state), patch.object(radio, 'bus',
return_value=[{'Status': {'data': state}}]) as bus:
with self.assertRaisesRegex(RuntimeError, '^RADIO_NOT_REGISTERED$'):
radio.request({'version': 1, 'operation': 'sms',
'number': '+12025550123', 'body': 'test'})
self.assertEqual(bus.call_count, 1)
def test_modem_queue_is_not_delivery_confirmation(self):
for state in ('registered', 'roaming'):
with self.subTest(state=state), patch.object(radio, 'bus', side_effect=[
[{'Status': {'data': state}}], ['/ril_0/messageabc']]):
result = radio.request({'version': 1, 'operation': 'sms',
'number': '+12025550123', 'body': 'test'})
self.assertEqual(result['state'], 'pending')
self.assertEqual(result['path'], '/ril_0/messageabc')
def test_hangup_only_accepts_owned_voice_paths(self):
with self.assertRaises(ValueError):
radio.request({'version': 1, 'operation': 'hangup', 'path': '/org/freedesktop/secret'})
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,131 @@
# SPDX-License-Identifier: MPL-2.0
import importlib.util
from pathlib import Path
import stat
import sys
import unittest
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'tools'))
from ramdisk_reference import read_newc, resolve
from tundra import cpio
spec=importlib.util.spec_from_file_location('hal_policy',ROOT/'runtime/hal_policy.py')
policy=importlib.util.module_from_spec(spec);spec.loader.exec_module(policy)
class BootTests(unittest.TestCase):
def test_cpio_roundtrip_and_absolute_library_symlink(self):
data=cpio([('bin',stat.S_IFDIR|0o755,b'',0,0),('bin/tool',stat.S_IFREG|0o755,b'ELF',0,0),('bin/link',stat.S_IFLNK|0o777,b'/bin/tool',0,0)])
entries=read_newc(data)
self.assertEqual(resolve(entries,'bin/link'),b'ELF')
def test_hardlinked_busybox_payload_is_not_lost(self):
import gzip
data=bytearray(gzip.decompress(cpio([
('bin/busybox',stat.S_IFREG|0o755,b'',0,0),
('bin/last-applet',stat.S_IFREG|0o755,b'ELF payload',0,0)])))
offset=0
for _ in range(2):
fields=[int(data[offset+6+i*8:offset+14+i*8],16) for i in range(13)]
data[offset+6:offset+14]=b'00000007'
data[offset+38:offset+46]=b'00000002'
start=(offset+110+fields[11]+3)&~3
offset=(start+fields[6]+3)&~3
self.assertEqual(resolve(read_newc(bytes(data)),'bin/busybox'),b'ELF payload')
def test_cpio_rejects_invalid_header(self):
with self.assertRaises(ValueError):read_newc(b'x'*111)
def test_hal_keeps_display_but_removes_app_telemetry_and_flash_services(self):
text='''# upstream copyright
service vendor.hwcomposer-2-2 /vendor/bin/hw/composer
class hal animation
onrestart restart surfaceflinger
service vendor.pixelstats_vendor /vendor/bin/pixelstats
class hal
service flash_recovery /system/bin/install-recovery.sh
class main
on boot
start flash_recovery
start vendor.pixelstats_vendor
start vendor.hwcomposer-2-2
mount_all /vendor/etc/fstab.sargo
exec -- /system/bin/sh /vendor/bin/mutate.sh
write /dev/block/by-name/boot_a unsafe
write /sys/class/android_usb/android0/enable 1
write /dev/cpuset/cpus 0-7
'''
output,report=policy.filter_rc(text)
self.assertIn('start vendor.hwcomposer-2-2',output)
self.assertIn('# upstream copyright',output)
self.assertIn('write /dev/cpuset/cpus',output)
for forbidden in ('pixelstats','flash_recovery','mount_all','exec --','/dev/block','android_usb','restart surfaceflinger'):
self.assertNotIn(forbidden,output)
self.assertEqual(report['services'],['vendor.hwcomposer-2-2'])
def test_android_multiline_commands_preserve_their_section(self):
source = 'service zygote /system/bin/app_process \\\n --zygote\non boot\n write /dev/event-log-tags "# owned by logd\n"\n'
output, report = policy.filter_rc(source)
self.assertNotIn('app_process', output)
self.assertIn('write /dev/event-log-tags "# owned by logd\n"', output)
def test_ueventd_permissions_are_not_parsed_as_init_commands(self):
for name in ('ueventd.rc','ueventd.sargo.rc','ueventd.qcom.rc'):
self.assertFalse(policy.is_init_script(Path('/android/vendor')/name))
self.assertTrue(policy.is_init_script(Path('/android/init.sargo.rc')))
def test_trial_service_failure_does_not_request_device_reboot(self):
output,_=policy.filter_rc('service servicemanager /system/bin/servicemanager\n critical\n class core\n')
self.assertNotIn('critical',output)
self.assertIn('class core',output)
def test_display_keeps_its_platform_properties_and_configstore(self):
output,report=policy.filter_rc('on post-fs-data\n load_system_props\nservice vendor.configstore-hal /vendor/bin/hw/android.hardware.configstore@1.1-service\n class hal animation\n user system\n')
self.assertIn('load_system_props',output)
self.assertIn('vendor.configstore-hal',report['services'])
def test_native_config_uses_a_valid_shell_and_denies_block_devices(self):
import tempfile
spec = importlib.util.spec_from_file_location('native_prepare', ROOT/'runtime/prepare-native.py')
native = importlib.util.module_from_spec(spec); spec.loader.exec_module(native)
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root/'opt/vulpes/trial').mkdir(parents=True)
(root/'opt/vulpes/trial/session.py').touch()
native.prepare(root)
shells = (root/'etc/shells').read_text().splitlines()
for line in (root/'etc/passwd').read_text().splitlines():
if line.startswith(('root:', 'droidian:')):
self.assertIn(line.split(':')[-1], shells)
config = (root/'etc/tundra/lxc.conf').read_text()
self.assertIn('lxc.cgroup.devices.deny = b *:* rwm', config)
self.assertIn('lxc.cgroup2.devices.deny = b *:* rwm', config)
self.assertNotIn('host', config)
self.assertIn('lxc.cgroup2.memory.max = 536870912', config)
service=(root/'etc/systemd/system/tundra-display.service').read_text()
self.assertIn('ExecStartPre=+chvt 7', service)
self.assertNotIn('ExecStartPost=', service)
# systemd-user's PAM stack includes these on the reference root.
for name in ('common-account', 'common-session-noninteractive'):
self.assertIn('pam_unix.so', (root/'etc/pam.d'/name).read_text())
self.assertIn('pam_deny.so', (root/'etc/pam.d/other').read_text())
def test_hal_denies_indirect_service_start_and_mount_of_block_device(self):
for line in ('setprop ctl.start zygote','setprop ctl.restart vendor.pixelstats_vendor',
'mount ext4 /dev/mmcblk0p72 /data','mount tmpfs /dev/block/x /data'):
self.assertFalse(policy.command_allowed(line.split()),line)
def test_interactive_boot_checks_are_opt_in(self):
import tempfile
spec = importlib.util.spec_from_file_location('native_prepare', ROOT/'runtime/prepare-native.py')
native = importlib.util.module_from_spec(spec); spec.loader.exec_module(native)
for diagnostics in (False, True):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root/'opt/vulpes/trial').mkdir(parents=True)
(root/'opt/vulpes/trial/session.py').touch()
native.prepare(root, diagnostics=diagnostics)
service = (root/'etc/systemd/system/tundra-display.service').read_text()
self.assertEqual('--diagnostics' in service, diagnostics)
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,99 @@
# SPDX-License-Identifier: MPL-2.0
import hashlib
import importlib.util
import io
import json
from pathlib import Path
import subprocess
import tarfile
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
def load(name, path):
spec = importlib.util.spec_from_file_location(name, ROOT/path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
stage = load('stage_runtime', 'tools/stage-runtime.py')
export = load('export_runtime', 'droidian/export-runtime.py')
display = load('compositor_trial', 'droidian/compositor-trial.py')
class ReferenceTests(unittest.TestCase):
def test_snapshot_excludes_configuration_and_private_trees(self):
for path in ('/etc/shadow', '/home/droidian/.ssh/id_ed25519',
'/var/lib/NetworkManager/secret', '/usr/local/private', '/usr/../etc/shadow'):
self.assertFalse(export.allowed(path), path)
self.assertTrue(export.allowed('/usr/share/doc/phoc/copyright'))
def test_dependency_alternative_and_versioned_virtual_provider(self):
def package(version, depends='', provides=''):
return {'Version': version, 'Architecture': 'arm64', 'Depends': depends,
'Pre-Depends': '', 'Provides': provides}
packages = {'app': package('1', 'missing | interface (>= 2)'),
'old': package('1', provides='interface (= 1)'),
'new': package('3', provides='interface (= 3)')}
def compare(args, **kwargs):
self.assertEqual(args[:2], ['dpkg', '--compare-versions'])
self.assertEqual(args[3:], ['>=', '2'])
return subprocess.CompletedProcess(args, 0 if args[2] == '3' else 1)
with patch.object(export.subprocess, 'run', side_effect=compare):
self.assertEqual(set(export.closure(packages, ['app'])), {'app', 'new'})
def test_unresolved_dependency_is_not_silently_dropped(self):
with self.assertRaisesRegex(ValueError, 'No installed provider'):
export.closure({}, ['missing'])
def test_legacy_lib_files_share_the_runtime_loader_directory(self):
with tempfile.TemporaryDirectory() as temporary:
source = Path(temporary)/'source'; source.mkdir()
data = b'reference library'; name = 'lib/example.so'
with tarfile.open(source/'runtime.tar', 'w') as tar:
info = tarfile.TarInfo(name); info.size = len(data); info.mode = 0o6755
tar.addfile(info, io.BytesIO(data))
manifest = {'archiveSha256': hashlib.sha256((source/'runtime.tar').read_bytes()).hexdigest(),
'files': {'/'+name: {'sha256': hashlib.sha256(data).hexdigest()}},
'packages': {'example': {}}, 'missing': []}
(source/'manifest.json').write_text(json.dumps(manifest))
root = Path(temporary)/'root'; stage.stage(source, root)
self.assertTrue((root/'lib').is_symlink())
self.assertEqual((root/'lib/example.so').read_bytes(), data)
self.assertEqual((root/'lib/example.so').stat().st_mode & 0o6000, 0)
self.assertFalse((root/'etc/machine-id').exists())
def test_archive_paths_cannot_escape_root(self):
for path in ('/usr/bin/x', 'usr/../../etc/shadow', 'etc/shadow'):
with self.assertRaises(ValueError):
stage.destination(path)
def test_gaia_child_drops_all_process_capabilities(self):
path = ROOT/'droidian/session.py'
code = "import runpy; runpy.run_path("+repr(str(path))+")[\"drop_capabilities\"](); print(open('/proc/self/status').read())"
result = subprocess.check_output(['python3', '-c', code], text=True)
values = dict(line.split(':', 1) for line in result.splitlines() if ':' in line)
for key in ('CapInh', 'CapPrm', 'CapEff', 'CapAmb'):
self.assertEqual(int(values[key].strip(), 16), 0, key)
def test_display_restore_continues_after_stop_timeout(self):
calls = []
def command(*args, **kwargs):
calls.append(args)
if args[:2] == ('systemctl', 'stop'):
raise subprocess.TimeoutExpired(args, 45)
return subprocess.CompletedProcess(args, 0, '', '')
with tempfile.TemporaryDirectory() as temporary:
p = Path(temporary)
with patch.object(display, 'SERVICE', p/'service'), \
patch.object(display, 'DROPIN', p/'dropin'), \
patch.object(display, 'CONFIG', p/'config'), \
patch.object(display, 'command', side_effect=command):
report = {}; display.restore(report)
self.assertIn(('systemctl', 'start', 'phosh.service'), calls)
self.assertTrue(report['restoredPhosh'])
self.assertEqual(len(report['cleanupErrors']), 1)

View file

@ -0,0 +1,30 @@
import base64
import importlib.util
from pathlib import Path
import struct
import unittest
spec = importlib.util.spec_from_file_location('slot_finalize', Path(__file__).resolve().parents[1]/'tools/finalize-sargo-slot.py')
slot = importlib.util.module_from_spec(spec)
spec.loader.exec_module(slot)
def snapshot(success=False, corrupt=False, change_b=False):
data=bytearray(256)
struct.pack_into('<Q',data,48,(1<<50)|(slot.SUCCESS if success else 0))
if corrupt:data[32]=1
if change_b:data[128+54]|=0x40
copy={'table':base64.b64encode(data).decode(),'partitions':{
'boot_a':{'index':1,'attributes':struct.unpack_from('<Q',data,48)[0]},
'boot_b':{'index':2,'attributes':0}}}
return {'primary':copy,'secondary':copy}
class SlotFinalizationTests(unittest.TestCase):
def test_accepts_only_success_bit_and_is_idempotent(self):
self.assertEqual(slot.verify_transition(snapshot(),snapshot(True)),['boot_a'])
self.assertEqual(slot.verify_transition(snapshot(True),snapshot(True)),[])
def test_rejects_partition_extent_or_other_slot_change(self):
for after in (snapshot(True,corrupt=True),snapshot(True,change_b=True),snapshot()):
with self.assertRaises(ValueError):slot.verify_transition(snapshot(),after)

View file

@ -0,0 +1,43 @@
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec=importlib.util.spec_from_file_location('sms_store',Path(__file__).resolve().parents[1]/'runtime/sms-inbox.py')
store=importlib.util.module_from_spec(spec)
spec.loader.exec_module(store)
class SmsStoreTests(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory()
self.root=Path(self.temp.name)
transaction=store.transaction
self.override=patch.object(store,'transaction',lambda change,**kw: transaction(change,root=self.root,**kw))
self.override.start()
def tearDown(self):
self.override.stop()
self.temp.cleanup()
def test_terminal_state_survives_message_removal(self):
store.record_outgoing('/ril_0/messageabc','sent')
self.assertEqual(store.outgoing_state('/ril_0/messageabc'),'sent')
store.record_outgoing('/ril_0/messageabc','pending')
self.assertEqual(store.outgoing_state('/ril_0/messageabc'),'sent')
self.assertEqual((self.root/'outgoing.json').stat().st_mode & 0o777,0o600)
def test_outgoing_does_not_overwrite_unacknowledged_incoming(self):
store.transaction(lambda rows:rows.append({'receipt':'receipt'}))
store.record_outgoing('/ril_0/messageabc','failed')
self.assertEqual(store.pending(),[{'receipt':'receipt'}])
store.acknowledge(['receipt'])
self.assertEqual(store.pending(),[])
self.assertEqual(store.outgoing_state('/ril_0/messageabc'),'failed')
def test_latest_terminal_state_replaces_same_path(self):
store.record_outgoing('/ril_0/messageabc','failed')
store.record_outgoing('/ril_0/messageabc','sent')
rows=json.loads((self.root/'outgoing.json').read_text())
self.assertEqual(len(rows),1)
self.assertNotIn('body',rows[0])
self.assertNotIn('number',rows[0])
if __name__=='__main__': unittest.main()

View file

@ -0,0 +1,40 @@
# SPDX-License-Identifier: MPL-2.0
import importlib.util
from pathlib import Path
import subprocess
import sys
import unittest
from unittest.mock import patch
TOOLS = Path(__file__).resolve().parents[1]/'tools'
sys.path.insert(0, str(TOOLS))
spec = importlib.util.spec_from_file_location('ssh_inventory', TOOLS/'collect-device-ssh.py')
inventory = importlib.util.module_from_spec(spec)
spec.loader.exec_module(inventory)
class SSHInventoryTests(unittest.TestCase):
def test_model_and_compatible_do_not_confuse_sargo_and_bonito(self):
self.assertEqual(inventory.identify('Google Pixel 3a\0', ''), 'sargo')
self.assertEqual(inventory.identify('', 'google,sargo\0qcom,sdm670\0'), 'sargo')
self.assertEqual(inventory.identify('Google Pixel 3a XL', 'google,sargo'), 'wrong-device-pixel-3a-xl')
self.assertEqual(inventory.identify('Qualcomm SDM670', ''), 'unverified')
self.assertEqual(inventory.identify('Google Inc. MSM sdm670 S4 PVT v1.0',
'google,b4s4-sdm670\0qcom,sdm670\0', 'sargo\n'), 'sargo')
self.assertEqual(inventory.identify('Google Pixel 3a', '', 'bonito'), 'wrong-device-pixel-3a-xl')
def test_remote_argument_is_quoted_and_fallback_connection_disabled(self):
base = inventory.ssh_base('/tmp/test-control', '10.15.19.82', 'droidian')
self.assertIn('ProxyCommand=false', base)
result = subprocess.CompletedProcess([], 0, '', '')
with patch.object(inventory.subprocess, 'run', return_value=result) as run:
inventory.capture(base, ['dpkg-query', '-f', '${Version}\n'])
self.assertEqual(run.call_args.args[0][-1], "dpkg-query -f '${Version}\n'")
self.assertNotIn('shell', run.call_args.kwargs)
def test_read_commands_have_no_privileged_or_mutating_entrypoints(self):
allowed = {'id', 'cat', 'uname', 'lsblk', 'ls', 'systemctl', 'dpkg-query', 'getprop'}
for command in inventory.READS.values():
self.assertIn(command[0], allowed)
if command[0] == 'systemctl':
self.assertEqual(command[1], 'list-units')

View file

@ -0,0 +1,45 @@
# SPDX-License-Identifier: MPL-2.0
"""Exercise ownership/format safeguards without writing to a real device."""
import importlib.util
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('system_image', ROOT/'tools/build-system-image.py')
image = importlib.util.module_from_spec(spec)
spec.loader.exec_module(image)
class SystemImageTests(unittest.TestCase):
def test_pack_refuses_real_ownership_changes_outside_fakeroot(self):
with patch.dict(os.environ, {}, clear=True), patch.object(image.os, 'lchown') as chown:
with self.assertRaisesRegex(RuntimeError, 'fakeroot'):
image.pack(Path('/not-a-real-root'), Path('/not-a-real-image'))
chown.assert_not_called()
def test_pack_keeps_system_owned_by_root_and_session_owned_by_app_user(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)/'root'
for name in ('opt/vulpes/host', 'home/droidian', 'etc'):
(root/name).mkdir(parents=True)
config = root/'etc/os-release'; config.touch()
app = root/'opt/vulpes/host/shell.js'; app.touch()
owners = {}
with patch.dict(os.environ, {'FAKEROOTKEY': 'test'}), \
patch.object(image.os, 'lchown', side_effect=lambda p,u,g: owners.update({p:(u,g)})), \
patch.object(image.subprocess, 'run') as run:
image.pack(root, Path(temporary)/'system.ext4')
self.assertEqual(owners[config], (0,0))
self.assertEqual(owners[app], (32011,32011))
args = run.call_args.args[0]
features = args[args.index('-O')+1]
self.assertTrue(features.startswith('none,'))
self.assertNotIn('orphan_file', features)
self.assertIn('TUNDRA_SYSTEM', args)
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,113 @@
# SPDX-License-Identifier: MPL-2.0
import hashlib
import importlib.util
import io
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))
import boot_backup
spec = importlib.util.spec_from_file_location('temporary_boot',TOOLS/'test-sargo-boot.py')
boot = importlib.util.module_from_spec(spec)
spec.loader.exec_module(boot)
class TemporaryBootTests(unittest.TestCase):
def test_different_phone_rejected_without_fastboot_queries(self):
with patch.object(boot,'getvar') as getvar:
with self.assertRaisesRegex(ValueError,'differs from the inventoried'):
boot.check_device('OTHER',hashlib.sha256(b'EXPECTED').hexdigest())
getvar.assert_not_called()
def test_locked_phone_and_slot_b_rejected(self):
expected = hashlib.sha256(b'SARGO').hexdigest()
for field,value in [('unlocked','no'),('current-slot','b'),('product','bonito'),('partition-size:boot_a','0x1000')]:
values = {'product':'sargo','unlocked':'yes','current-slot':'a','partition-size:boot_a':'0x4000000'}
values[field] = value
with patch.object(boot,'getvar',side_effect=lambda serial,name:values[name]):
with self.assertRaises(ValueError):
boot.check_device('SARGO',expected)
def test_mutating_fastboot_commands_are_not_implemented(self):
with patch.object(boot.subprocess,'run') as run:
for args in [('flash','boot_a','image.img'),('erase','userdata'),('set_active','b'),('flashing','unlock')]:
with self.assertRaises(ValueError):
boot.fastboot('SARGO',*args)
run.assert_not_called()
def test_fastboot_variable_formats(self):
for line in ['(bootloader) current-slot: a','current-slot: a']:
result = subprocess.CompletedProcess([],0,'',line+'\nFinished. Total time: 0.001s\n')
with patch.object(boot,'fastboot',return_value=result):
self.assertEqual(boot.getvar('SARGO','current-slot'),'a')
def test_default_mode_cannot_reboot_or_boot(self):
with patch.object(sys,'argv',['test-sargo-boot.py']), \
patch.object(boot,'checked_plan',return_value=({},Path('image.img'))), \
patch.object(boot,'reboot_from_droidian') as reboot, \
patch.object(boot,'fastboot') as fastboot, \
patch.object(boot,'find_fastboot') as find, \
patch.object(boot.os,'umask'), patch('sys.stdout',new_callable=io.StringIO):
boot.main()
reboot.assert_not_called()
fastboot.assert_not_called()
find.assert_not_called()
def test_collect_resumes_without_reboot_or_fastboot(self):
with tempfile.TemporaryDirectory() as temp:
root=Path(temp);logs=root/'logs/device';logs.mkdir(parents=True)
(root/'VALIDATION.json').write_text(json.dumps({'rawSlotADiagnostic':{}}))
image=root/'image.img';image.write_bytes(b'diagnostic')
sha=hashlib.sha256(image.read_bytes()).hexdigest()
previous={'operation':'fastboot boot','fastbootOutput':'Booting OKAY',
'imageSha256':sha,'serialSha256':'phone'}
(logs/'temporary-boot-original.json').write_text(json.dumps(previous))
plan={'diagnosticImageSha256':sha,'serialSha256':'phone'}
response={'status':'TUNDRA_USB_STATUS protocol=1 readOnly=true\nEND\n',
'version':'Linux version 4.9-124-google-sargo\nEND\n',
'mounts':'proc /proc proc ro 0 0\nEND\n',
'invalid':'ERROR unsupported command\nEND\n'}
with patch.object(sys,'argv',['test-sargo-boot.py','--collect']), \
patch.object(boot,'ROOT',root), \
patch.object(boot,'checked_plan',return_value=(plan,image)), \
patch.object(boot,'find_diagnostic',return_value=Path('/dev/bus/usb/003/011')), \
patch.object(boot,'device_layout',return_value={'interface':0,'in':129,'out':1}), \
patch.object(boot,'exchange',return_value=response), \
patch.object(boot,'reboot_from_droidian') as reboot, \
patch.object(boot,'fastboot') as fastboot, \
patch.object(boot.os,'umask'),patch('sys.stdout',new_callable=io.StringIO):
boot.main()
reboot.assert_not_called()
fastboot.assert_not_called()
reports=[json.loads(p.read_text()) for p in logs.glob('temporary-boot-*.json') if p.name!='temporary-boot-original.json']
self.assertEqual(len(reports),1)
self.assertTrue(reports[0]['passed'])
self.assertEqual(reports[0]['operation'],'usb collect')
self.assertEqual(reports[0]['previousBoot']['report'],'logs/device/temporary-boot-original.json')
validation=json.loads((root/'VALIDATION.json').read_text())
self.assertTrue(validation['rawSlotADiagnostic']['hardwareTested'])
self.assertFalse(validation['sargoHardwareDiagnostic']['gaiaIncluded'])
class RawBackupTests(unittest.TestCase):
def test_corruption_missing_file_and_unexpected_file_rejected(self):
with tempfile.TemporaryDirectory() as temp, patch.object(boot_backup,'SIZES',{'boot_a.img':4}):
folder=Path(temp);part=folder/'boot_a.img';part.write_bytes(b'boot')
m={'device':'sargo','rawPartitions':True,'files':{'boot_a.img':{'bytes':4,'sha256':hashlib.sha256(b'boot').hexdigest()}}}
manifest=folder/'manifest.json';manifest.write_text(json.dumps(m))
self.assertEqual(boot_backup.verify(folder),m)
part.write_bytes(b'bad!')
with self.assertRaisesRegex(ValueError,'checksum'):
boot_backup.verify(folder)
part.unlink()
with self.assertRaisesRegex(ValueError,'file or size'):
boot_backup.verify(folder)
m['files']['../escape']={};manifest.write_text(json.dumps(m))
with self.assertRaisesRegex(ValueError,'exactly'):
boot_backup.verify(folder)

134
tundra/tests/test_tools.py Normal file
View file

@ -0,0 +1,134 @@
# SPDX-License-Identifier: MPL-2.0
import gzip
import importlib.util
import json
from pathlib import Path
import stat
import struct
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
import tundra
spec = importlib.util.spec_from_file_location("collector", tundra.ROOT / "tools/collect-device.py")
collector = importlib.util.module_from_spec(spec)
spec.loader.exec_module(collector)
class PackagingTests(unittest.TestCase):
def test_cpio_roundtrip_and_reproducibility(self):
entries = [("dev", stat.S_IFDIR | 0o755, b"", 0, 0),
("dev/console", stat.S_IFCHR | 0o600, b"", 5, 1),
("init", stat.S_IFREG | 0o755, b"init-payload", 0, 0)]
a = tundra.cpio(entries)
self.assertEqual(a, tundra.cpio(entries))
data = gzip.decompress(a)
cursor = 0
decoded = {}
while True:
self.assertEqual(data[cursor:cursor + 6], b"070701")
fields = [int(data[cursor + 6 + 8 * n:cursor + 14 + 8 * n], 16) for n in range(13)]
cursor += 110
name = data[cursor:cursor + fields[11] - 1].decode()
cursor = (cursor + fields[11] + 3) & ~3
payload = data[cursor:cursor + fields[6]]
cursor = (cursor + fields[6] + 3) & ~3
if name == "TRAILER!!!":
break
decoded[name] = (fields, payload)
self.assertEqual(decoded["init"][1], b"init-payload")
self.assertEqual(decoded["dev/console"][0][9:11], [5, 1])
self.assertEqual(decoded["init"][0][1], stat.S_IFREG | 0o755)
# Independent parser: GNU cpio, never extract a device node onto the host.
p = subprocess.run(["cpio", "-it"], input=data, capture_output=True, check=True)
self.assertIn(b"dev/console", p.stdout)
self.assertIn(b"init", p.stdout)
def test_cpio_rejects_escape(self):
for name in ("/init", "../init", "dev/../../init"):
with self.assertRaises(ValueError):
tundra.cpio([(name, stat.S_IFREG | 0o755, b"", 0, 0)])
def test_manifest_inheritance_and_cycle(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "main.xml").write_text('<manifest><remote name="a" fetch="https://example.org/"/><default remote="a" revision="tag"/><include name="sub.xml"/></manifest>')
(root / "sub.xml").write_text('<manifest><project name="kernel" path="src/kernel"/></manifest>')
self.assertEqual(tundra.flatten_manifest(root, "main.xml"),
[{"path": "src/kernel", "url": "https://example.org/kernel", "revision": "tag"}])
(root / "sub.xml").write_text('<manifest><include name="main.xml"/></manifest>')
with self.assertRaises(ValueError):
tundra.flatten_manifest(root, "main.xml")
def test_sargo_boot_roundtrip(self):
folder = tundra.ROOT / "out/sargo-stage0"
metadata_file = folder / "image.json"
if not metadata_file.exists():
self.skipTest("Run ./tundra prepare-sargo first")
info = json.loads(metadata_file.read_text())
image = folder / info["file"]
self.assertEqual(tundra.digest(image), info["sha256"])
self.assertFalse(info["flashReady"])
with tempfile.TemporaryDirectory() as d:
subprocess.run([sys.executable, str(tundra.checked_source("aosp-mkbootimg") / "unpack_bootimg.py"),
"--boot_img", str(image), "--out", d], check=True, capture_output=True)
self.assertEqual((Path(d) / "ramdisk").read_bytes(), (folder / "initramfs.cpio.gz").read_bytes())
kernel = tundra.ROOT / "downloads/aosp-sargo-kernel" / info["kernelSource"]["commit"] / "Image.lz4-dtb"
self.assertEqual(tundra.digest(Path(d) / "kernel"), tundra.digest(kernel))
self.assertEqual(struct.unpack_from("<H", (folder / "init").read_bytes(), 18)[0], 183)
def test_diagnostic_refuses_host_execution(self):
binary = tundra.ROOT / "out/qemu-x86_64/init"
if not binary.exists():
self.skipTest("Run ./tundra test-vm first")
p = subprocess.run([str(binary)], capture_output=True, timeout=3)
self.assertEqual(p.returncode, 2)
self.assertIn(b"only run as PID 1", p.stdout)
class CollectorTests(unittest.TestCase):
def test_reject_wrong_device(self):
result = lambda x: {"returncode": 0, "stdout": x}
self.assertEqual(collector.identify({"ro.product.device": result("bonito")}), "wrong-device-pixel-3a-xl")
self.assertEqual(collector.identify({"ro.product.device": result("sargo")}), "sargo")
self.assertEqual(collector.identify({"device-model": result("Google Pixel 3a\0")}), "sargo-model-only")
self.assertEqual(collector.identify({"device-model": result("Qualcomm SDM670")}), "unverified")
self.assertEqual(collector.identify({"device-model": {"returncode": 1, "stdout": "Google Pixel 3a"}}), "unverified")
def test_collection_is_read_only_and_selects_transport(self):
commands = []
def capture(command):
commands.append(command)
value = "device" if command[-1] == "get-state" else "sargo" if command[-1] == "ro.product.device" else ""
return {"returncode": 0, "stdout": value, "stderr": ""}
with patch.object(sys, "argv", ["collect", "--serial", "TEST123"]), \
patch.object(collector, "capture", side_effect=capture), \
patch.object(collector, "save") as save, patch.object(collector.os, "umask"):
self.assertEqual(collector.main(), 0)
self.assertTrue(save.call_args.args[1]["readOnly"])
self.assertFalse(save.call_args.args[1]["flashAuthorizedByThisReport"])
for command in commands:
self.assertEqual(command[:3], ["adb", "-s", "TEST123"])
self.assertIn(command[3], ("get-state", "shell"))
if command[3] == "shell":
self.assertIn(command[4], ("getprop", "cat", "uname", "ls"))
self.assertTrue(set(command).isdisjoint({"reboot", "root", "push", "flash", "erase", "mount", "unlock"}))
def test_unauthorized_device_is_not_queried(self):
with patch.object(sys, "argv", ["collect", "--serial", "TEST123"]), \
patch.object(collector, "capture", return_value={"returncode": 1}) as capture, \
patch.object(collector, "save") as save, patch.object(collector.os, "umask"):
with self.assertRaises(SystemExit):
collector.main()
self.assertEqual(capture.call_count, 1)
save.assert_not_called()
if __name__ == "__main__":
unittest.main()

36
tundra/tests/test_usb.py Normal file
View file

@ -0,0 +1,36 @@
# SPDX-License-Identifier: MPL-2.0
import gzip
import json
from pathlib import Path
import struct
import subprocess
import sys
import tempfile
import unittest
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools'))
from tundra import ROOT, checked_source, digest
class USBPackagingTests(unittest.TestCase):
def test_refuses_host_execution(self):
init=ROOT/'out/usb-diagnostic/x86_64/init'
if not init.exists(): self.skipTest('Build USB diagnostic first')
result=subprocess.run([str(init)],capture_output=True,text=True,timeout=5)
self.assertEqual(result.returncode,2)
self.assertIn('must be PID 1',result.stdout)
def test_sargo_usb_independent_unpack(self):
folder=ROOT/'out/usb-diagnostic'
if not (folder/'build.json').exists():self.skipTest('Build USB diagnostic first')
report=json.loads((folder/'build.json').read_text())
image=folder/report['file']
self.assertFalse(report['flashReady'])
self.assertEqual(digest(image),report['imageSha256'])
with tempfile.TemporaryDirectory() as tmp:
result=subprocess.run([sys.executable,str(checked_source('aosp-mkbootimg')/'unpack_bootimg.py'),'--boot_img',str(image),'--out',tmp],check=True,capture_output=True,text=True)
self.assertIn('tundra.usb=1',result.stdout)
ramdisk=(Path(tmp)/'ramdisk').read_bytes()
self.assertEqual(ramdisk,(folder/'aarch64/initramfs.cpio.gz').read_bytes())
elf=subprocess.run(['cpio','-i','--to-stdout','init'],input=gzip.decompress(ramdisk),capture_output=True,check=True).stdout
self.assertEqual(elf[:4],b'\x7fELF')
self.assertEqual(struct.unpack_from('<H',elf,18)[0],183)
self.assertEqual(elf,(folder/'aarch64/init').read_bytes())

View file

@ -0,0 +1,64 @@
# SPDX-License-Identifier: MPL-2.0
import ctypes
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools'))
import usb_protocol as usb
# Captured through sysfs from the actual Sargo diagnostic, high-speed config 1.
SARGO = bytes.fromhex('12011002000000406b1d04010904010203010902200001010080fa0904000002ff0000040705810200020007050102000200')
class USBTransportTests(unittest.TestCase):
def test_pixel_remapped_endpoint(self):
self.assertEqual(usb.parse_endpoints(SARGO,1),{'interface':0,'in':0x81,'out':0x01})
def test_original_endpoint_and_different_interface(self):
descriptors=bytearray(SARGO)
descriptors[-5]=2
descriptors[29]=4
self.assertEqual(usb.parse_endpoints(descriptors,1),{'interface':4,'in':0x81,'out':0x02})
def test_invalid_and_ambiguous_descriptors_rejected(self):
both_in=bytearray(SARGO);both_in[-5]=0x82
interrupt=bytearray(SARGO);interrupt[-4]=3
for data in (b'',b'\0\2',SARGO[:-1],both_in,interrupt,SARGO+SARGO[27:]):
with self.subTest(data=data):
with self.assertRaises(ValueError):
usb.parse_endpoints(data,1)
with self.assertRaises(ValueError):
usb.parse_endpoints(SARGO,2)
def test_transfer_uses_discovered_addresses_and_claims_interface(self):
layout={'interface':4,'out':1,'in':0x81}
endpoints=[]
response=b'TUNDRA_USB_STATUS protocol=1 readOnly=true\nEND\n'
def ioctl(fd,code,pointer):
request=ctypes.cast(pointer,ctypes.POINTER(usb.Bulk)).contents
endpoints.append(request.ep)
if len(endpoints)==1:
self.assertEqual(request.ep,1)
self.assertEqual(ctypes.string_at(request.data,request.length),b'status\n')
return request.length
self.assertEqual(request.ep,0x81)
ctypes.memmove(request.data,response,len(response))
return len(response)
with tempfile.TemporaryFile() as stream, \
patch.object(usb,'device_layout',return_value=layout), \
patch.object(usb.Path,'open',return_value=stream), \
patch.object(usb.fcntl,'ioctl') as claim, \
patch.object(usb.ctypes,'CDLL',return_value=SimpleNamespace(ioctl=Mock(side_effect=ioctl))):
self.assertEqual(usb.exchange('/dev/bus/usb/003/011',('status',)),{'status':response.decode()})
self.assertEqual(claim.call_args.args[2],usb.struct.pack('I',4))
self.assertEqual(endpoints,[1,0x81])
def test_invalid_command_rejected_before_device_access(self):
with patch.object(usb,'device_layout') as layout:
with self.assertRaises(ValueError):
usb.exchange('/dev/bus/usb/003/011',('status\nversion',))
layout.assert_not_called()

View file

@ -0,0 +1,120 @@
# SPDX-License-Identifier: MPL-2.0
import importlib.util
import json
from pathlib import Path
import socket
import stat
import struct
import tempfile
import threading
from types import SimpleNamespace
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location('wifi', Path(__file__).resolve().parents[1]/'runtime/wifi-control.py')
wifi = importlib.util.module_from_spec(spec)
spec.loader.exec_module(wifi)
AP = {'id': '/org/freedesktop/NetworkManager/AccessPoint/7',
'ssid': 'Test: réseau\\', 'security': 'wpa-psk', 'signal': 75}
class WifiTests(unittest.TestCase):
def test_nmcli_escaped_names_and_enterprise(self):
raw = AP['id'] + r':Test\: réseau\\:75:WPA2:*' + '\n'
raw += '/org/freedesktop/NetworkManager/AccessPoint/8:Enterprise:60:WPA2 802.1X:\n'
with patch.object(wifi, 'nm', return_value=SimpleNamespace(returncode=0, stdout=raw)):
result = wifi.networks()
self.assertEqual(result[0]['ssid'], AP['ssid'])
self.assertTrue(result[0]['connected'])
self.assertEqual(result[1]['security'], 'unsupported')
def test_secret_persists_only_after_success(self):
for fail in (False, True):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
commands = []
def nm(*args, **kwargs):
commands.append(args)
if args[:2] == ('connection', 'load'):
file = Path(args[2])
self.assertEqual(stat.S_IMODE(file.stat().st_mode), 0o600)
self.assertIn('psk=testpassword', file.read_text())
return SimpleNamespace(returncode=int(fail and args[:2] == ('connection', 'up')))
with patch.object(wifi, 'networks', return_value=[AP]), patch.object(wifi, 'nm', side_effect=nm):
request = {'operation':'connect', 'id':AP['id'], 'password':'testpassword'}
if fail:
with self.assertRaisesRegex(wifi.WifiError, 'WIFI_CONNECT_FAILED'):
wifi.dispatch(request, root)
else:
self.assertTrue(wifi.dispatch(request, root)['connected'])
files=list(root.iterdir())
self.assertEqual(len(files), 0 if fail else 1)
if not fail:
self.assertIn('autoconnect=true', files[0].read_text())
self.assertEqual(wifi.saved_profiles(root)[0]['ssid'], AP['ssid'])
self.assertNotIn('testpassword', repr(commands))
def test_remember_reconnect_failed_password_and_forget(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
with patch.object(wifi, 'networks', return_value=[AP]), patch.object(wifi, 'nm', return_value=SimpleNamespace(returncode=0)):
wifi.dispatch({'operation':'connect','id':AP['id'],'password':'firstpassword'},root)
stored = wifi.dispatch({'operation':'known'},root)['networks'][0]
self.assertNotIn('password', stored)
wifi.dispatch({'operation':'connect','id':AP['id']},root)
target = next(root.iterdir())
before = target.read_text()
def failing(*args, **kwargs):
return SimpleNamespace(returncode=int(args[:2]==('connection','up')))
with patch.object(wifi, 'nm', side_effect=failing):
with self.assertRaises(wifi.WifiError):
wifi.dispatch({'operation':'connect','id':AP['id'],'password':'wrongpassword'},root)
self.assertEqual(target.read_text(),before)
wifi.dispatch({'operation':'forget','id':stored['id']},root)
self.assertEqual(list(root.iterdir()),[])
def test_rejects_commands_injection_and_unobserved_networks(self):
with patch.object(wifi, 'nm') as command:
for request in ({'operation':'execute'}, {'operation':'scan','command':'id'}, []):
with self.assertRaises(wifi.WifiError):
wifi.dispatch(request)
command.assert_not_called()
with patch.object(wifi, 'networks', return_value=[]):
with self.assertRaisesRegex(wifi.WifiError, 'WIFI_NETWORK_GONE'):
wifi.dispatch({'operation':'connect','id':'/tmp/hostile'})
for password in ('short', 'testpass\n[connection]', 'x'*100):
with self.assertRaises(wifi.WifiError):
wifi.profile_text(AP, password, 'test')
def test_socket_round_trip_and_limits(self):
a, b = socket.socketpair()
with a, b, patch.object(wifi, 'dispatch', return_value={'networks':[]}) as dispatch:
# Peer credential enforcement is independent of the CI user's uid.
class Peer:
def getsockopt(self, *args): return struct.pack('3i', 123, 32011, 32011)
def __getattr__(self, name): return getattr(b, name)
thread = threading.Thread(target=wifi.handle, args=(Peer(),))
thread.start()
a.sendall(b'{"operation":"scan"}\n')
self.assertEqual(wifi.receive(a, 65536), {'result':{'networks':[]}})
thread.join(2)
self.assertFalse(thread.is_alive())
dispatch.assert_called_once_with({'operation':'scan'})
a, b = socket.socketpair()
with a, b:
a.sendall(b'12345\n')
with self.assertRaises(wifi.WifiError): wifi.receive(b, 4)
def test_unauthorized_peer_never_dispatches(self):
class Peer:
def settimeout(self, _): pass
def getsockopt(self, *args): return struct.pack('3i', 123, 65534, 65534)
def sendall(self, data): self.reply = json.loads(data)
peer = Peer()
with patch.object(wifi, 'dispatch') as dispatch:
wifi.handle(peer)
dispatch.assert_not_called()
self.assertEqual(peer.reply, {'error':'PERMISSION_DENIED'})
if __name__ == '__main__': unittest.main()

View file

@ -0,0 +1,54 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Inspect the embedded configuration of our pinned sargo kernel, without a phone."""
import json
from pathlib import Path
import subprocess
import zlib
from tundra import ROOT, digest, now, save
def inspect(path):
# The AOSP artifact appends device trees to an LZ4 stream. lz4 reports trailing
# data, so validate the embedded gzip/config payload rather than its exit code.
result = subprocess.run(['lz4','-dc',str(path)],capture_output=True,check=False)
start=result.stdout.find(b'IKCFG_ST')
if start<0: raise ValueError('Kernel has no embedded IKCONFIG signature')
stream=zlib.decompressobj(31)
raw=stream.decompress(result.stdout[start+8:])
if not stream.eof or not stream.unused_data.startswith(b'IKCFG_ED'):
raise ValueError('Invalid compressed kernel config or terminator')
config=raw.decode()
if not config.startswith('#\n# Automatically generated file; DO NOT EDIT.'):
raise ValueError('Unexpected kernel config format')
values={}
for line in config.splitlines():
if line.startswith('CONFIG_'):
key,value=line.split('=',1);values[key]=value
elif line.startswith('# CONFIG_') and line.endswith(' is not set'):
values[line.split()[1]]='n'
keys=['CONFIG_ARM64','CONFIG_DEVTMPFS','CONFIG_CONFIGFS_FS','CONFIG_USB_DWC3',
'CONFIG_USB_DWC3_MSM','CONFIG_USB_CONFIGFS','CONFIG_USB_CONFIGFS_ACM',
'CONFIG_USB_CONFIGFS_F_FS','CONFIG_PSTORE','CONFIG_PSTORE_RAM','CONFIG_PSTORE_CONSOLE']
return config,{key:values.get(key,'absent') for key in keys}
def main():
# Source layout is owned by the common tooling.
from tundra import locked
commit=locked('aosp-sargo-kernel')['commit']
path=ROOT/'downloads/aosp-sargo-kernel'/commit/'Image.lz4-dtb'
config,features=inspect(path)
folder=ROOT/'out/sargo';folder.mkdir(parents=True,exist_ok=True)
(folder/'kernel.config').write_text(config)
report={'recordedAt':now(),'kernelSha256':digest(path),'sourceCommit':commit,
'features':features,'hardwareTested':False,
'usbDiagnosticReady':False,
'conclusions':['USB FunctionFS is compiled in; an actual userspace service is still needed.',
'USB ACM is not compiled in; a generic ttyGS0 console will not work with this prebuilt.',
'devtmpfs is not compiled in; Android uevent/device-node handling must be provided.',
'pstore support does not prove reserved RAM works on this phone.']}
save(folder/'kernel-audit.json',report)
print(json.dumps(report,indent=2))
if __name__=='__main__': main()

View file

@ -0,0 +1,62 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Back up boot partitions through the existing SSH master; sudo stays in the user's terminal."""
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
def main():
if not sys.stdin.isatty():
raise RuntimeError('Run in your terminal: sudo may ask for the phone password there')
socket = ROOT / 'logs/device/ssh-control'
spec = importlib.util.spec_from_file_location('collector', ROOT / 'tools/collect-device-ssh.py')
collector = importlib.util.module_from_spec(spec)
spec.loader.exec_module(collector)
base = collector.ssh_base(socket, '10.15.19.82', 'droidian')
subprocess.run(base[:-1] + ['-O', 'check', base[-1]], check=True)
source = (ROOT / 'droidian/backup-boot.py').read_bytes()
sha = hashlib.sha256(source).hexdigest()
remote = '/home/droidian/vulpes-tundra-tests/backup-boot-' + sha[:16] + '.py'
subprocess.run(base + [shlex.join(['python3', '-c',
'import pathlib,sys;pathlib.Path(sys.argv[1]).write_bytes(sys.stdin.buffer.read())', remote])],
input=source, check=True)
# Password entry goes directly to the terminal, never to logs or the assistant.
subprocess.run(base[:-1] + ['-t', base[-1], shlex.join(['sudo', '/usr/bin/python3', '-I', remote])], check=True)
listing = subprocess.check_output(base + [shlex.join(['python3', '-c',
"import pathlib;print('\\n'.join(str(p) for p in sorted(pathlib.Path('/var/tmp').glob('vulpes-boot-backup-*/manifest.json')) if p.is_file()))"])], text=True)
os.umask(0o077)
destination = ROOT.parents[2] / 'backups/tundra/sargo/raw-boot'
for entry in listing.splitlines():
folder = Path(entry).parent
if folder.parent != Path('/var/tmp') or not folder.name.startswith('vulpes-boot-backup-'):
raise RuntimeError('Unexpected remote backup path')
read = lambda p: subprocess.check_output(base + [shlex.join(['cat', str(p)])], timeout=120)
manifest_bytes = read(folder / 'manifest.json')
manifest = json.loads(manifest_bytes)
expected = {'boot_a.img', 'boot_b.img', 'dtbo_a.img', 'dtbo_b.img', 'vbmeta_a.img', 'vbmeta_b.img'}
if manifest.get('device') != 'sargo' or set(manifest.get('files', {})) != expected:
raise RuntimeError('Invalid backup manifest')
local = destination / folder.name
local.mkdir(parents=True, exist_ok=True)
for name, info in manifest['files'].items():
target = local / name
data = target.read_bytes() if target.exists() else read(folder / name)
if len(data) != info['bytes'] or hashlib.sha256(data).hexdigest() != info['sha256']:
raise RuntimeError('Backup checksum mismatch: ' + name)
if not target.exists():
with target.open('xb') as output:
output.write(data)
(local / 'manifest.json').write_bytes(manifest_bytes)
print('Verified on PC:', local)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,26 @@
# SPDX-License-Identifier: MPL-2.0
"""Validate the inventoried Sargo partition set before any boot experiment."""
import json
from pathlib import Path
from tundra import digest
SIZES = {'boot_a.img': 67108864, 'boot_b.img': 67108864,
'dtbo_a.img': 8417280, 'dtbo_b.img': 8417280,
'vbmeta_a.img': 65536, 'vbmeta_b.img': 65536}
def verify(folder):
folder = Path(folder).resolve()
manifest = json.loads((folder / 'manifest.json').read_text())
if manifest.get('device') != 'sargo' or manifest.get('rawPartitions') is not True:
raise ValueError('Expected a raw Sargo partition backup')
if set(manifest.get('files', {})) != set(SIZES):
raise ValueError('Expected exactly boot, DTBO and vbmeta for both slots')
for name, size in SIZES.items():
path = folder / name
record = manifest['files'][name]
if path.is_symlink() or not path.is_file() or path.stat().st_size != size or record['bytes'] != size:
raise ValueError('Unexpected partition file or size: ' + name)
if digest(path) != record['sha256']:
raise ValueError('Partition checksum mismatch: ' + name)
return manifest

View file

@ -0,0 +1,31 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Cross-compile the local, version-pinned GLVND adapter; no system installation."""
import json
from pathlib import Path
import subprocess
from tundra import ROOT, digest, linker, now, save
def main():
source = ROOT/'droidian/graphics/egl-config.c'
folder = ROOT/'out/droidian-graphics'
folder.mkdir(parents=True, exist_ok=True)
target = folder/'libEGL_vulpes_hybris.so'
command = ['clang','--target=aarch64-linux-gnu',f'--ld-path={linker()}',
'-ffreestanding','-fPIC','-shared','-nostdlib','-Os',
'-Wall','-Wextra','-Werror','-I/usr/include',str(source),'-o',str(target)]
subprocess.run(command, check=True)
manifest = json.loads((ROOT/'droidian/graphics/vendor.json').read_text())
manifest.update(adapterSha256=digest(target),sourceSha256=digest(source))
save(folder/'manifest.json',manifest)
save(folder/'build.json',{'recordedAt':now(),'command':command,
'compiler':subprocess.check_output(['clang','--version'],text=True).splitlines()[0],
'headers':{p:digest(Path('/usr/include')/p) for p in
['glvnd/libeglabi.h','glvnd/GLdispatchABI.h','EGL/egl.h','EGL/eglext.h','EGL/eglplatform.h','KHR/khrplatform.h']},
'adapterSha256':digest(target)})
print(target)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,111 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Build a diagnostic using verified /boot copies. Never writes to a device."""
import argparse
import json
from pathlib import Path
import shlex
import stat
import struct
import subprocess
import sys
import tempfile
from tundra import ROOT, checked_source, cpio, digest, linker, now, save
from boot_backup import verify as verify_backup
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--boot-copies', required=True, type=Path)
parser.add_argument('--raw-backup', type=Path,
help='Use the verified raw slot A header, retaining the inventoried kernel')
args = parser.parse_args()
copies = args.boot_copies.resolve()
manifest = json.loads((copies/'manifest.json').read_text())
if manifest.get('device') != 'sargo':
raise ValueError('Expected verified sargo boot copies')
for item in manifest['files']:
path = copies/item['file']
if path.parent != copies or digest(path) != item['sha256'] or path.stat().st_size != item['bytes']:
raise ValueError('Boot copy changed: '+item['file'])
original = copies/('boot.img-'+manifest['runningKernel'])
folder = ROOT/'out/installed-sargo-diagnostic'
raw_manifest_hash = None
if args.raw_backup:
raw = args.raw_backup.resolve()
verify_backup(raw)
raw_manifest_hash = digest(raw/'manifest.json')
original = raw/'boot_a.img'
folder = ROOT/'out/sargo-raw-diagnostic'
folder.mkdir(parents=True, exist_ok=True)
source = ROOT/'boot/usb-init.c'
binary = folder/'init'
command = ['clang','--target=aarch64-linux-gnu',f'--ld-path={linker()}',
'-Os','-ffreestanding','-fno-builtin','-fno-stack-protector','-fno-pie',
'-fno-unwind-tables','-fno-asynchronous-unwind-tables',
'-Wall','-Wextra','-Werror','-nostdlib','-static','-Wl,--build-id=none',
'-Wl,-e,_start',str(source),'-o',str(binary)]
subprocess.run(command, check=True)
data = binary.read_bytes()
if data[:4] != b'\x7fELF' or struct.unpack_from('<H',data,18)[0] != 183:
raise ValueError('Expected ARM64 init')
entries = [(p,stat.S_IFDIR|0o755,b'',0,0) for p in ('dev','proc','sys')]
entries += [('dev/console',stat.S_IFCHR|0o600,b'',5,1),
('init',stat.S_IFREG|0o755,data,0,0)]
ramdisk = folder/'initramfs.cpio.gz'
ramdisk.write_bytes(cpio(entries))
image = folder/'tundra-sargo-installed-kernel-NOT-FOR-FLASHING.img'
packager = checked_source('aosp-mkbootimg')
with tempfile.TemporaryDirectory() as temp:
unpack = Path(temp)/'original'
result = subprocess.run([sys.executable,str(packager/'unpack_bootimg.py'),
'--boot_img',str(original),'--out',str(unpack),'--format=mkbootimg'],
check=True,capture_output=True,text=True)
options = shlex.split(result.stdout)
fields = dict(zip(options[::2],options[1::2]))
if fields.get('--header_version') != '0':
raise ValueError('This recipe expects the inventoried v0 boot')
kernel = unpack/'kernel'
if digest(kernel) != digest(copies/('Image.lz4-dtb-'+manifest['runningKernel'])):
raise ValueError('Extracted kernel differs from inventoried kernel')
fields['--ramdisk'] = str(ramdisk)
# Preserve the board parameters; our PID 1 never mounts userdata or vendor.
fields['--cmdline'] += ' rdinit=/init tundra.usb=1'
build = [sys.executable,str(packager/'mkbootimg.py')]
for key,value in fields.items():
build += [key,value]
subprocess.run(build+['-o',str(image)],check=True)
if image.stat().st_size > 64*1024*1024:
raise ValueError('Image exceeds observed boot partition size')
verify = Path(temp)/'verify'
result = subprocess.run([sys.executable,str(packager/'unpack_bootimg.py'),
'--boot_img',str(image),'--out',str(verify),'--format=mkbootimg'],
check=True,capture_output=True,text=True)
rebuilt = shlex.split(result.stdout)
rebuilt = dict(zip(rebuilt[::2],rebuilt[1::2]))
for key,value in fields.items():
if key not in ('--kernel','--ramdisk') and rebuilt.get(key) != value:
raise ValueError('Header roundtrip mismatch: '+key)
if digest(verify/'kernel') != digest(kernel) or digest(verify/'ramdisk') != digest(ramdisk):
raise ValueError('Kernel/ramdisk roundtrip mismatch')
metadata = {k:v for k,v in fields.items() if k not in ('--kernel','--ramdisk')}
kernel_hash = digest(kernel)
save(folder/'build.json',{
'recordedAt':now(),'device':'sargo','kernel':manifest['runningKernel'],
'originalBootSha256':digest(original),'bootCopiesManifestSha256':digest(copies/'manifest.json'),
'rawBackupManifestSha256':raw_manifest_hash,
'rawBootSlot':'a' if args.raw_backup else None,
'kernelSha256':kernel_hash,'ramdiskSha256':digest(ramdisk),
'initSha256':digest(binary),'sourceSha256':digest(source),
'sharedInitSha256':digest(ROOT/'boot/init.c'),'compileCommand':command,
'header':metadata,'file':image.name,'imageSha256':digest(image),
'bytes':image.stat().st_size,'roundtripPassed':True,'hardwareTested':False,
'flashReady':False,'gaiaIncluded':False,'partitionsWritten':[],
'blockers':['Restoration not validated',
*([] if args.raw_backup else ['Raw partition backup not verified by this build']),
'Qualcomm UDC startup not tested with this ramdisk']})
print(image)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,239 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Build a private, temporary Sargo SSH ramdisk from verified installed helpers."""
import argparse
import base64
import importlib.util
import json
import os
from pathlib import Path
import shlex
import stat
import struct
import subprocess
import tempfile
import time
from cryptography.hazmat.primitives.asymmetric import rsa, ec
from cryptography.hazmat.primitives import serialization
from ramdisk_reference import read_newc, select_helpers
from tundra import ROOT, cpio, checked_source, digest, save, linker
def load_boot():
spec=importlib.util.spec_from_file_location('boot_test',ROOT/'tools/test-sargo-boot.py')
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
return module
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output',type=Path,required=True)
parser.add_argument('--layout',type=Path,required=True)
parser.add_argument('--native',action='store_true',help='Switch to the staged native root with a ten-minute watchdog')
parser.add_argument('--system-image',type=Path,help='system.json for a self-contained image staged at /tundra/images/<sha256>.ext4')
parser.add_argument('--hold-hal',action='store_true',help='Boot basic.target first so HAL startup can be traced over USB')
parser.add_argument('--runtime-config', action='store_true', help='Apply the current runtime recipe to the RAM overlay')
parser.add_argument('--network', action='store_true', help='Enable experimental WLAN and isolated Qualcomm NV copies; requires --runtime-config')
parser.add_argument('--hardware', action='store_true', help='Enable experimental modem/audio/camera HAL services; requires --network')
parser.add_argument('--local-debugger',action='store_true',help='Private RAM diagnosis only: Marionette on loopback 2858; never flash this image')
parser.add_argument('--diagnostics', action='store_true', help='Open apps and scroll Gaia during qualification; requires --runtime-config')
parser.add_argument('--state', type=Path, help='Prepared Tundra data-image manifest; enables persistent writes in that file')
parser.add_argument('--no-deadline', action='store_true', help='Keep the persistent development boot running; requires --state')
args=parser.parse_args()
if args.hold_hal and not args.native:parser.error('--hold-hal requires --native')
if args.system_image and not args.native:parser.error('--system-image requires --native')
if args.runtime_config and not args.native:parser.error('--runtime-config requires --native')
if args.hardware and not args.network:parser.error('--hardware requires --network')
if args.network and not args.runtime_config:parser.error('--network requires --runtime-config')
if args.diagnostics and not args.runtime_config:parser.error('--diagnostics requires --runtime-config')
if args.state and (not args.system_image or not args.runtime_config):
parser.error('--state requires --system-image and --runtime-config')
if args.no_deadline and not args.state:parser.error('--no-deadline requires --state')
state = None
if args.state:
from persistent_state import validate_manifest
state = validate_manifest(args.state)
system = None
if args.system_image:
system = json.loads(args.system_image.read_text())
image_path = args.system_image.resolve().parent/system['image']
if (system.get('device') != 'sargo' or not system.get('filesystemCheckPassed')
or digest(image_path) != system['imageSha256']):
raise ValueError('System image provenance mismatch')
if state and state['systemImageSha256'] != system['imageSha256']:
raise ValueError('Data image belongs to another system image')
os.umask(0o077)
built_at=int(time.time())
out=args.output.resolve();out.mkdir(parents=True,exist_ok=False)
plan,_=load_boot().checked_plan()
layout=json.loads(args.layout.read_text())
if layout['device']!='/dev/mmcblk0p72' or layout['table']!='0 104448000 linear 259:40 329728' or int(layout['size'])!=53648801280:
raise ValueError('Recipe only supports the inventoried read-only Sargo layout')
source=Path(plan['backupDirectory'])/'boot_a.img'
packager=checked_source('aosp-mkbootimg')
unpack=out/'original'
result=subprocess.check_output(['python3',str(packager/'unpack_bootimg.py'),'--boot_img',str(source),'--out',str(unpack),'--format=mkbootimg'],text=True)
options=shlex.split(result);fields=dict(zip(options[::2],options[1::2]))
if fields['--header_version']!='0':raise ValueError('Expected header v0')
with tempfile.TemporaryDirectory() as temp:
helpers=select_helpers(read_newc((unpack/'ramdisk').read_bytes()),Path(temp),
extra=('sbin/blkid','sbin/e2fsck') if state else ())
files={name:(0o755 if name.startswith(('bin/','sbin/')) or '/ld-' in name else 0o644,content) for name,content in helpers.items()}
# ARM64 binaries in the Android ramdisk request the dynamic loader at
# /lib/ld-linux-aarch64.so.1. Some source ramdisks expose the same loader
# only below /lib/aarch64-linux-gnu; keep both paths in the trial image.
loader='lib/aarch64-linux-gnu/ld-linux-aarch64.so.1'
if loader in files:
files.setdefault('lib/ld-linux-aarch64.so.1', files[loader])
# Generate fresh trial credentials. Nothing from the installed ramdisk's
# /etc/dropbear or /root is copied. Keep this entire output private.
client=ec.generate_private_key(ec.SECP256R1())
(out/'client-key').write_bytes(client.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.OpenSSH,serialization.NoEncryption()))
public=client.public_key().public_bytes(serialization.Encoding.OpenSSH,serialization.PublicFormat.OpenSSH)
host=rsa.generate_private_key(public_exponent=65537,key_size=3072)
numbers=host.private_numbers()
def string(data):return struct.pack('>I',len(data))+data
def integer(n):
data=n.to_bytes((n.bit_length()+7)//8,'big')
return string((b'\0' if data[0]&128 else b'')+data)
# Dropbear src/rsa.c: type, e, n, d, p, q in SSH string/mpint format.
private=string(b'ssh-rsa')+b''.join(integer(n) for n in (numbers.public_numbers.e,numbers.public_numbers.n,numbers.d,numbers.p,numbers.q))
hostpublic=host.public_key().public_bytes(serialization.Encoding.OpenSSH,serialization.PublicFormat.OpenSSH).decode()
(out/'known_hosts').write_text('[10.15.19.82]:2222 '+hostpublic+'\n[127.0.0.1]:2222 '+hostpublic+'\n')
init=(ROOT/'boot/rescue-init.sh').read_bytes().replace(b'# No LVM activation',
('EXPECTED_STORAGE_HASH='+shlex.quote(layout['firstMiBSha256'])+'\n# No LVM activation').encode())
sources=[Path(__file__),ROOT/'boot/rescue-init.sh',ROOT/'tools/ramdisk_reference.py']
if state:
# Keep the existing layout intact; only /tundra/data/<UUID>.ext4 is used
# for Tundra state. Opening its containing filesystem RW is necessary.
init=init.replace(b'--readonly --table', b'--table')
init=init.replace(b'-o ro,noload /dev/mapper/tundra-installed-ro', b'-o rw /dev/mapper/tundra-installed-ro')
init=init.replace(b'# Temporary RAM-only boot. Installed data is never mounted writable.', b'# Private persistent boot. Only the dedicated Tundra data file receives system writes.')
init=init.replace(b'# No LVM activation, fsck, journal replay, resizing, or writes to a block device.', b'# Use the inventoried filesystem without resizing or formatting it.')
sources.append(ROOT/'tools/persistent_state.py')
shared_ui = {}
if args.native:
binary=out/'watchdog'
subprocess.run(['clang','--target=aarch64-linux-gnu','--ld-path='+str(linker()),'-Os','-ffreestanding','-fno-builtin','-fno-stack-protector','-fno-pie','-fno-unwind-tables','-fno-asynchronous-unwind-tables','-Wall','-Wextra','-Werror','-nostdlib','-static','-Wl,--build-id=none','-Wl,-e,_start',str(ROOT/'boot/watchdog.c'),'-o',str(binary)],check=True)
init=init.replace(b"(sleep 480; echo 'Tundra trial deadline reached'; reboot -f) &",b'/sbin/tundra-watchdog &')
if args.no_deadline:
init=init.replace(b'/sbin/tundra-watchdog &', b'# Persistent development boot: no timed reboot.')
init=init.replace(b'while :; do sleep 5; done',b'exec /bin/sh /native-root.sh')
import io,tarfile
with tempfile.TemporaryDirectory() as temporary:
configroot=Path(temporary)/'root'
(configroot/'opt/vulpes/trial').mkdir(parents=True)
(configroot/'opt/vulpes/trial/session.py').touch()
spec=importlib.util.spec_from_file_location('native',ROOT/'runtime/prepare-native.py')
native=importlib.util.module_from_spec(spec);spec.loader.exec_module(native);native.prepare(configroot, network=args.network, hardware=args.hardware, diagnostics=args.diagnostics)
display=configroot/'etc/systemd/system/tundra-display.service'
if display.exists():
display.write_text(display.read_text().replace('[Service]',
'[Service]\nEnvironment=TUNDRA_LOCAL_DEBUGGER='+('1' if args.local_debugger else '0')))
if state:
dropin=configroot/'etc/systemd/system/tundra-display.service.d'
dropin.mkdir(parents=True,exist_ok=True)
(dropin/'persistent.conf').write_text('[Service]\nEnvironment=TUNDRA_PERSISTENT=1\n')
users=configroot/'etc/systemd/system/systemd-sysusers.service.d'
users.mkdir(parents=True,exist_ok=True)
# Add newly required service accounts even when a previous
# boot marked /etc up to date. Existing identities are retained.
(users/'tundra.conf').write_text('[Unit]\nConditionNeedsUpdate=\nConditionCredential=\n')
buffer=io.BytesIO()
def owned(info):
info.uid=info.gid=0
info.uname=info.gname='root'
info.mtime=0
if info.isdir():info.mode=0o755
elif info.isfile() and info.name.endswith('.conf'):info.mode=0o644
return info
with tarfile.open(fileobj=buffer,mode='w') as archive:
# Most runtime policy lives under /etc, but a few reference
# applications select device backends from /usr/lib/droidian.
# Include every generated top-level directory so a native
# trial can override those selectors in its disposable layer.
for directory in ('etc', 'usr'):
config_dir = configroot/directory
if config_dir.exists():
archive.add(config_dir, arcname=directory, filter=owned)
files['native-config.tar']=(0o600,buffer.getvalue())
sources += list(sorted((ROOT/'runtime').glob('*.py')))
files['sbin/tundra-watchdog']=(0o755,binary.read_bytes())
native_init=(ROOT/'boot/native-root.sh').read_bytes().replace(b'# BUILD_TIME_FLOOR', ('BUILD_EPOCH='+str(built_at)).encode())
if args.runtime_config:
native_init=native_init.replace(b'# RUNTIME_CONFIG_OVERLAY', b'APPLY_NATIVE_CONFIG=1')
if system:
sha = system['imageSha256']
native_init = native_init.replace(b'# A kernel fallback',
('SYSTEM_IMAGE=/installed/tundra/images/'+sha+'.ext4\nSYSTEM_IMAGE_SHA='+sha+'\n# A kernel fallback').encode())
# Bundle UI changes with the boot trial rather than retransferring
# the entire immutable system image for each CSS/JS adjustment.
buffer = io.BytesIO()
selected = [*sorted((ROOT.parent/'host').rglob('*')),
*sorted((ROOT.parent/'services').rglob('*.mjs')),
*sorted((ROOT.parent/'gaia/shared/elements/vulpes-fonts').rglob('*')),
*sorted((ROOT.parent/'overrides').rglob('*')),
ROOT.parent/'assets/webapps/search.gaiamobile.org/manifest.webapp',
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']
with tarfile.open(fileobj=buffer, mode='w') as archive:
for path in selected:
if not path.is_file(): continue
relative = str(path.relative_to(ROOT.parent))
target = 'trial/'+path.name if path in (ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py') else relative
archive.add(path, arcname='opt/vulpes/'+target, filter=owned)
shared_ui[relative] = digest(path)
files['shared-ui.tar'] = (0o600, buffer.getvalue())
if state:
config = ('STATE_IMAGE=/installed/tundra/data/'+state['uuid']+'.ext4\n'
'STATE_UUID='+state['uuid']+'\nSTATE_BYTES='+str(state['bytes'])+'\n')
native_init=native_init.replace(b'# A kernel fallback',config.encode()+b'# A kernel fallback')
if args.hold_hal:native_init=native_init.replace(b'--unit=tundra.target',b'--unit=basic.target')
files['native-root.sh']=(0o755,native_init)
sources += [ROOT/'boot/watchdog.c',ROOT/'boot/init.c',ROOT/'boot/native-root.sh']
files.update({'init':(0o755,init),'etc/tundra-hostkey':(0o600,private),
'root/.ssh/authorized_keys':(0o600,public+b'\n'),
'etc/passwd':(0o644,b'root:x:0:0:Tundra development:/root:/bin/sh\n'),
'etc/shadow':(0o600,b'root::20000:0:99999:7:::\n'),
'etc/group':(0o644,b'root:x:0:\n'),
'etc/shells':(0o644,b'/bin/sh\n'),
'etc/nsswitch.conf':(0o644,b'passwd: files\ngroup: files\nshadow: files\n')})
directories={'dev','proc','sys','run','installed'}
for name in files:
directories.update(str(p) for p in Path(name).parents if str(p)!='.')
entries=[(n,stat.S_IFDIR|(0o700 if n.startswith('root') else 0o755),b'',0,0) for n in sorted(directories)]
entries += [(n,stat.S_IFREG|mode,data,0,0) for n,(mode,data) in sorted(files.items())]
entries += [('bin/sh',stat.S_IFLNK|0o777,b'busybox',0,0),('dev/console',stat.S_IFCHR|0o600,b'',5,1)]
ramdisk=out/'initramfs.cpio.gz';ramdisk.write_bytes(cpio(entries))
kernel_hash=digest(unpack/'kernel')
fields['--ramdisk']=str(ramdisk);fields['--cmdline']+=' rdinit=/init tundra.rescue=1 panic=10'
image=out/('tundra-sargo-PRIVATE-PERSISTENT.img' if state else 'tundra-sargo-PRIVATE-RAM-TRIAL.img')
command=['python3',str(packager/'mkbootimg.py')]
for k,v in fields.items():command.extend([k,v])
subprocess.run(command+['-o',str(image)],check=True)
if image.stat().st_size>67108864:raise ValueError('Boot image too large')
verify=out/'verify'
subprocess.run(['python3',str(packager/'unpack_bootimg.py'),'--boot_img',str(image),'--out',str(verify)],check=True,stdout=subprocess.DEVNULL)
if digest(verify/'kernel')!=digest(unpack/'kernel') or digest(verify/'ramdisk')!=digest(ramdisk):raise ValueError('Roundtrip failed')
import shutil
# The source ramdisk contains installed private keys: retain no extracted copy.
shutil.rmtree(unpack);shutil.rmtree(verify)
# Keep the inputs beside the immutable image. Later edits to the checkout
# must not invalidate an otherwise intact, qualified recovery trial.
snapshot=out/'sources'
for path in sources + [ROOT.parent/name for name in shared_ui]:
target=snapshot/path.relative_to(ROOT.parent)
target.parent.mkdir(parents=True,exist_ok=True)
shutil.copy2(path,target)
save(out/'build.json',{'scope':'Private persistent Sargo development boot; unqualified' if state else ('Private temporary native-root trial; unqualified' if args.native else 'Private temporary SSH boot; installed root read-only; no graphical session'),
'builtAtUnix':built_at,'runtimeConfigOverlay':args.runtime_config,'networkTrial':args.network,'hardwareTrial':args.hardware,'interactiveDiagnostics':args.diagnostics,'image':image.name,'imageSha256':digest(image),'sourceBootSha256':digest(source),
'serialSha256':plan['serialSha256'],'kernelSha256':kernel_hash,
'ramdiskSha256':digest(ramdisk),'roundtripPassed':True,'flashReady':False,'localDebugger':args.local_debugger,'watchdogSeconds':None if args.no_deadline else (600 if args.native else 480),'persistentState':state,'storageWrites':bool(state),'nativeRootRequested':args.native,'halHeldForDiagnostics':args.hold_hal,
'layout':layout,'layoutSha256':digest(args.layout),'systemImage':system,'sharedUiHashes':shared_ui,'sourceSnapshot':True,'sources':{str(p.relative_to(ROOT)):digest(p) for p in sources},
'helpers':{n:__import__('hashlib').sha256(b).hexdigest() for n,b in helpers.items()},
'containsPrivateTrialHostKey':True,'installedKeysCopied':False})
print(image)
if __name__=='__main__':main()

View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Package the ARM64 runtime reference. This image has no qualified boot sequence."""
import argparse
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
def digest(path):
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def pack(tree, image):
if not os.environ.get('FAKEROOTKEY'):
raise RuntimeError('Ownership normalization must run under fakeroot')
for base, dirs, files in os.walk(tree):
os.lchown(base, 0, 0)
for name in dirs+files:
os.lchown(Path(base)/name, 0, 0)
os.chown(tree/'home/vulpes', 1000, 1000)
# Explicit feature list avoids enabling newer ext4 features on the 4.9 kernel.
features = 'none,has_journal,ext_attr,dir_index,filetype,extent,sparse_super,large_file,uninit_bg,dir_nlink,extra_isize'
subprocess.run(['mke2fs', '-q', '-t', 'ext4', '-F', '-b', '4096', '-I', '256',
'-O', features, '-L', 'TUNDRA_REF', '-d', str(tree), str(image)], check=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source', type=Path, default=ROOT/'downloads/sargo-installed/runtime-reference')
parser.add_argument('--product', type=Path, default=ROOT/'out/droidian/product')
parser.add_argument('--output', type=Path, default=ROOT/'out/sargo-runtime-image')
parser.add_argument('--pack', nargs=2, type=Path, help=argparse.SUPPRESS)
args = parser.parse_args()
if args.pack:
pack(*args.pack)
return
if os.geteuid() == 0:
parser.error('Run as a regular user; fakeroot supplies image ownership')
output = args.output.resolve()
if output.exists():
parser.error('Output exists; select a new directory to preserve its provenance')
spec = importlib.util.spec_from_file_location('stage_runtime', ROOT/'tools/stage-runtime.py')
stage = importlib.util.module_from_spec(spec); spec.loader.exec_module(stage)
tree = output/'root'
staged = stage.stage(args.source, tree)
product = args.product.resolve()
selected = ('assets', 'host', 'services', 'adapters', 'overrides', 'gaia', 'tools',
'tests', 'engines', 'trial', 'engine-lock.json', 'release.json')
for name in selected:
source, target = product/name, tree/'opt/vulpes'/name
if source.is_dir():
shutil.copytree(source, target, symlinks=True,
ignore=shutil.ignore_patterns('__pycache__'), copy_function=os.link)
else:
os.link(source, target)
image = output/'tundra-sargo-runtime-NOT-BOOTABLE.ext4'
with image.open('xb') as stream:
stream.truncate(2*1024**3)
subprocess.run(['fakeroot', '--', sys.executable, str(Path(__file__).resolve()),
'--pack', str(tree), str(image)], check=True)
check = subprocess.run(['e2fsck', '-f', '-n', str(image)], capture_output=True, text=True)
(output/'filesystem-check.txt').write_text(check.stdout+check.stderr)
if check.returncode:
raise RuntimeError('Generated filesystem failed e2fsck')
metadata = {'scope': 'Runtime and shared Gaia/Gecko files; no init/HAL configuration, NOT BOOTABLE',
'flashReady': False, 'standaloneBootTested': False, 'staging': staged,
'image': image.name, 'imageSha256': digest(image), 'bytes': image.stat().st_size,
'engine': json.loads((product/'engine-lock.json').read_text()),
'filesystemCheckPassed': True,
'scripts': {p.name: digest(p) for p in (Path(__file__).resolve(), ROOT/'tools/stage-runtime.py')}}
(output/'manifest.json').write_text(json.dumps(metadata, indent=2)+'\n')
print(image, metadata['imageSha256'], flush=True)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,56 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Create a new private data image; never formats a phone partition."""
import argparse
import json
import os
from pathlib import Path
import subprocess
import uuid
from persistent_state import FEATURES, digest
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--system-image', type=Path, required=True)
parser.add_argument('--size-mib', type=int, default=2048)
args = parser.parse_args()
if args.size_mib < 512 or args.size_mib > 8192:
parser.error('Choose 512–8192 MiB')
system = json.loads(args.system_image.read_text())
base = args.system_image.parent/system['image']
if system.get('device') != 'sargo' or not system.get('filesystemCheckPassed') or digest(base) != system['imageSha256']:
raise ValueError('System image provenance mismatch')
os.umask(0o077)
out = args.output.resolve()
out.mkdir(parents=True, exist_ok=False)
seed = out/'seed'
seed.mkdir()
(seed/'system.sha256').write_text(system['imageSha256']+'\n')
for directory in ('upper','work'):
(seed/directory).mkdir(mode=0o755 if directory == 'upper' else 0o700)
(seed/'upper').chmod(0o755)
identifier = str(uuid.uuid4())
image = out/(identifier+'.ext4')
with image.open('xb') as stream:
stream.truncate(args.size_mib*1024**2)
subprocess.run(['mke2fs','-q','-t','ext4','-F','-b','4096','-I','256','-O',FEATURES,
'-U',identifier,'-L','TUNDRA_DATA','-d',str(seed),str(image)],check=True)
# Root owns the overlay metadata, regardless of the build account's UID.
for name in ('/system.sha256','/upper','/work'):
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)
check = subprocess.run(['e2fsck','-f','-n',str(image)],text=True,capture_output=True)
(out/'filesystem-check.txt').write_text(check.stdout+check.stderr)
if check.returncode:raise RuntimeError('Data image failed filesystem verification')
manifest = {'schema':1,'device':'sargo','uuid':identifier,'image':image.name,
'imageSha256':digest(image),'bytes':image.stat().st_size,
'systemImageSha256':system['imageSha256'],'filesystemCheckPassed':True,
'scope':'Private persistent overlay; containing filesystem retained; no partition formatting'}
(out/'state.json').write_text(json.dumps(manifest,indent=2)+'\n')
print(out/'state.json')
if __name__ == '__main__':main()

View file

@ -0,0 +1,129 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Assemble a private Sargo system image, including its Android HAL reference."""
import argparse
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
PROJECT = ROOT.parent
ANDROID_SHA = '132d0e18c6a6a111293447e940267f35aa2b14fa00a872ef160b20dc2014d886'
def digest(path):
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def module(name, path):
spec = importlib.util.spec_from_file_location(name, path)
result = importlib.util.module_from_spec(spec)
spec.loader.exec_module(result)
return result
def pack(root, image):
if not os.environ.get('FAKEROOTKEY'):
raise RuntimeError('Image ownership must be prepared under fakeroot')
for directory, dirs, files in os.walk(root):
for path in [Path(directory), *[Path(directory)/n for n in dirs+files]]:
os.lchown(path, 0, 0)
# The trial session refreshes its local Gecko host files before starting.
for base in (root/'opt/vulpes', root/'home/droidian'):
for directory, dirs, files in os.walk(base):
for path in [Path(directory), *[Path(directory)/n for n in dirs+files]]:
os.lchown(path, 32011, 32011)
features = 'none,has_journal,ext_attr,dir_index,filetype,extent,sparse_super,large_file,uninit_bg,dir_nlink,extra_isize'
subprocess.run(['mke2fs', '-q', '-t', 'ext4', '-F', '-b', '4096', '-I', '256',
'-O', features, '-L', 'TUNDRA_SYSTEM', '-d', str(root), str(image)], check=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--reference', type=Path, default=ROOT/'downloads/sargo-installed/runtime-reference-20260925')
parser.add_argument('--product', type=Path, default=ROOT/'out/droidian/product')
parser.add_argument('--pack', nargs=2, type=Path, help=argparse.SUPPRESS)
args = parser.parse_args()
if args.pack:
pack(*args.pack)
return
if os.geteuid() == 0:
parser.error('Build as a regular user')
android = ROOT/'downloads/sargo-installed/android-rootfs.img'
if digest(android) != ANDROID_SHA:
raise ValueError('Android HAL reference changed')
lock = json.loads((args.product/'engine-lock.json').read_text())
current = json.loads((PROJECT/'engine-lock.json').read_text())
if lock.get('architecture') != 'aarch64' or lock['version'] != current['version']:
raise ValueError('Prepare the matching ARM64 engine first')
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=False)
root = output/'root'
staging = module('stage', ROOT/'tools/stage-runtime.py').stage(args.reference, root)
product = root/'opt/vulpes'
# Read UI/services from the common checkout, and only the engine/EGL adapter
# from the checked ARM64 product. Never copy profiles or phone configuration.
for name in ('assets', 'host', 'services', 'adapters', 'overrides', 'gaia/shared', 'gaia/apps/system'):
shutil.copytree(PROJECT/name, product/name, symlinks=True,
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'):
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')
shutil.copy2(args.product/'engine-lock.json', product/'engine-lock.json')
shutil.copytree(args.product/'engines', product/'engines', symlinks=True)
shutil.copytree(ROOT/'droidian', product/'trial', ignore=shutil.ignore_patterns('__pycache__'))
for name in ('libEGL_vulpes_hybris.so', 'manifest.json'):
shutil.copy2(args.product/'trial/graphics'/name, product/'trial/graphics'/name)
for name in ('home/droidian', 'etc/dbus-1', 'reference'):
(root/name).mkdir(parents=True, exist_ok=True)
shutil.copy2(android, root/'reference/android-rootfs.img')
module('native', ROOT/'runtime/prepare-native.py').prepare(root)
shutil.copy2(ROOT/'droidian/initialize-runtime.sh', root/'etc/tundra/initialize-runtime.sh')
# Caches are architecture dependent; generate them with the target binaries.
(root/'etc/systemd/system/tundra-initialize.service').write_text('''[Unit]
Description=Initialize Tundra runtime caches
Before=tundra-hal.service tundra-display.service
[Service]
Type=oneshot
ExecStart=/bin/sh /etc/tundra/initialize-runtime.sh
RemainAfterExit=yes
''')
for service in ('tundra-hal', 'tundra-display'):
folder = root/f'etc/systemd/system/{service}.service.d'
folder.mkdir(exist_ok=True)
(folder/'initialize.conf').write_text('[Unit]\nRequires=tundra-initialize.service\nAfter=tundra-initialize.service\n')
image = output/'tundra-sargo-system.ext4'
with image.open('xb') as stream:
stream.truncate(3*1024**3)
subprocess.run(['fakeroot', '--', sys.executable, str(Path(__file__).resolve()),
'--output', str(output), '--pack', str(root), str(image)], check=True)
check = subprocess.run(['e2fsck', '-f', '-n', str(image)], capture_output=True, text=True)
(output/'filesystem-check.txt').write_text(check.stdout+check.stderr)
if check.returncode:
raise RuntimeError('System image failed filesystem verification')
manifest = {'schema': 1, 'device': 'sargo', 'image': image.name,
'imageSha256': digest(image), 'bytes': image.stat().st_size,
'androidSha256': ANDROID_SHA, 'engine': lock,
'release': json.loads((product/'release.json').read_text()),
'staging': staging, 'filesystemCheckPassed': True,
'flashReady': False, 'hardwareBootTested': False,
'scope': 'Self-contained userspace; vendor/firmware partitions still required; RAM boot candidate',
'sourceHashes': {str(p.relative_to(PROJECT)): digest(p) for p in
[Path(__file__).resolve(), ROOT/'tools/stage-runtime.py',
*sorted((ROOT/'runtime').glob('*.py'))]}}
(output/'system.json').write_text(json.dumps(manifest, indent=2)+'\n')
print(image, manifest['imageSha256'], flush=True)
if __name__ == '__main__':
main()

44
tundra/tools/build-usb.py Normal file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Compile a read-only FunctionFS diagnostic. Does not access a phone."""
import json
from pathlib import Path
import stat
import struct
import subprocess
import sys
from tundra import ROOT, cpio, linker, digest, now, save, kernel_files, checked_source
def main():
folder=ROOT/'out/usb-diagnostic';folder.mkdir(parents=True,exist_ok=True)
common=['-Os','-ffreestanding','-fno-builtin','-fno-stack-protector','-fno-pie',
'-fno-unwind-tables','-fno-asynchronous-unwind-tables','-Wall','-Wextra','-Werror','-nostdlib','-static']
builds={}
for arch in ('x86_64','aarch64'):
out=folder/arch;out.mkdir(exist_ok=True)
cmd=(['gcc','-no-pie','-mgeneral-regs-only'] if arch=='x86_64' else ['clang','--target=aarch64-linux-gnu',f'--ld-path={linker()}'])
cmd += common+['-Wl,--build-id=none','-Wl,-e,_start',str(ROOT/'boot/usb-init.c'),'-o',str(out/'init')]
subprocess.run(cmd,check=True)
data=(out/'init').read_bytes()
assert struct.unpack_from('<H',data,18)[0]==(62 if arch=='x86_64' else 183)
entries=[(p,stat.S_IFDIR|0o755,b'',0,0) for p in ('dev','proc','sys')]
entries += [('dev/console',stat.S_IFCHR|0o600,b'',5,1),('init',stat.S_IFREG|0o755,data,0,0)]
(out/'initramfs.cpio.gz').write_bytes(cpio(entries))
builds[arch]={'initSha256':digest(out/'init'),'ramdiskSha256':digest(out/'initramfs.cpio.gz'),'command':cmd}
kernel=kernel_files();board=json.loads((ROOT/'devices/sargo/device.json').read_text())['diagnosticBoot']
image=folder/'tundra-sargo-usb-NOT-FOR-FLASHING.img'
cmd=[sys.executable,str(checked_source('aosp-mkbootimg')/'mkbootimg.py'),
'--kernel',str(kernel/'Image.lz4-dtb'),'--ramdisk',str(folder/'aarch64/initramfs.cpio.gz'),
'--header_version',str(board['headerVersion']),'--pagesize',str(board['pageSize']),
'--base',board['base'],'--kernel_offset',board['kernelOffset'],'--ramdisk_offset',board['ramdiskOffset'],
'--tags_offset',board['tagsOffset'],'--cmdline',board['cmdline']+' tundra.usb=1','-o',str(image)]
subprocess.run(cmd,check=True)
if image.stat().st_size>board['partitionSizeBytes']:raise ValueError('Image too large')
save(folder/'build.json',{'recordedAt':now(),'sourceSha256':digest(ROOT/'boot/usb-init.c'),
'architectures':builds,'sharedInitSha256':digest(ROOT/'boot/init.c'),'file':image.name,'imageSha256':digest(image),'bytes':image.stat().st_size,
'hardwareTested':False,'flashReady':False,'protocol':'read-only FunctionFS, not ADB',
'blockers':['Real firmware/slot/recovery inventory missing','Sargo UDC initialization not tested','No Gecko or Gaia in this diagnostic']})
print(image)
if __name__=='__main__':main()

254
tundra/tools/build-vm.py Normal file
View file

@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""Assemble an isolated x86 graphical prototype from pinned Vulpes and local Linux tools.
This is a development image, not a redistributable distro or a Pixel ROM.
No root, package installation, personal profile or host filesystem mount is needed.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import sys
import time
import urllib.request
from tundra import cpio
ROOT = Path(__file__).resolve().parents[1]
PROJECT = ROOT.parent
OUT = ROOT / 'out/graphical-vm'
FS = OUT / 'root'
WESTON = ROOT / 'out/graphical'
seen = set()
def sha(path):
with open(path, 'rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def logical(path):
s = str(path)
for prefix in ('/lib64/', '/lib/'):
if s.startswith(prefix):
return '/usr/lib/' + s[len(prefix):]
return s
def copy(path, dest=None, deps=True):
path = Path(path)
dest = logical(dest or path)
target = FS / dest.lstrip('/')
if dest in seen:
return
if not path.is_file():
raise FileNotFoundError(path)
seen.add(dest)
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
target.unlink()
shutil.copy2(path, target, follow_symlinks=True)
target.chmod(target.stat().st_mode & 0o777)
if deps and path.open('rb').read(4) == b'\x7fELF':
runtime = PROJECT / json.loads((PROJECT / 'engine-lock.json').read_text())['runtime']
env = dict(os.environ, LD_LIBRARY_PATH=f'{WESTON}/usr/lib/weston:{WESTON}/usr/lib:{runtime}')
result = subprocess.run(['ldd', str(path)], env=env, capture_output=True, text=True)
if 'not found' in result.stdout:
raise RuntimeError(f'Missing dependency for {path}: {result.stdout}')
for library in re.findall(r'(?:=>\s+|^\s*)(/\S+)', result.stdout, re.M):
destination = library
if library.startswith(str(WESTON)):
destination = library[len(str(WESTON)):]
elif library.startswith(str(PROJECT / 'engines')):
destination = '/opt/vulpes/' + str(Path(library).relative_to(PROJECT))
copy(library, destination)
def tree(source, destination, exclude=()):
source = Path(source)
for base, dirs, files in os.walk(source, followlinks=True):
dirs[:] = [d for d in dirs if d not in exclude]
for name in files:
p = Path(base) / name
if name.endswith(('.pyc', '.pyo')) or name in exclude:
continue
if p.is_file():
copy(p, str(Path(destination) / p.relative_to(source)))
def write(path, data, mode=0o644):
p = FS / path.lstrip('/')
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(data)
p.chmod(mode)
def initramfs(kernel):
minimal = OUT / 'initroot'
minimal.mkdir(exist_ok=True)
paths = set()
def include(path):
p = Path(logical(path))
if str(p) in paths:
return
paths.add(str(p))
output = subprocess.run(['ldd', str(p)], capture_output=True, text=True).stdout
for lib in re.findall(r'(?:=>\s+|^\s*)(/\S+)', output, re.M):
include(lib)
for binary in ('bash', 'mount', 'modprobe', 'switch_root', 'mkdir', 'sleep'):
include('/usr/bin/' + binary)
modules = set()
for module in ('virtio_pci', 'virtio_blk', 'ext4', 'virtio_gpu', 'virtio_input', 'evdev', 'dummy_hcd', 'libcomposite', 'usb_f_fs', 'usbcore'):
output = subprocess.check_output(['modprobe', '--show-depends', module], text=True)
for line in output.splitlines():
if line.startswith('insmod '):
modules.add(logical(line.split()[1]))
paths.update(modules)
moddir = Path('/usr/lib/modules') / kernel
paths.update(str(p) for p in moddir.glob('modules.*') if p.is_file())
entries = []
dirs = {'dev', 'proc', 'sys', 'newroot', 'run', 'usr', 'usr/bin', 'usr/lib'}
for path in sorted(paths):
p = Path(path)
name = path.lstrip('/')
dirs.update(str(d) for d in Path(name).parents if str(d) != '.')
entries.append((name, stat.S_IFREG | (p.stat().st_mode & 0o777), p.read_bytes(), 0, 0))
copy(p, deps=False)
script = '''#!/usr/bin/bash
set -eux
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev
mount -t tmpfs tmpfs /run
for m in virtio_pci virtio_blk ext4 virtio_gpu virtio_input evdev; do modprobe "$m"; done
for ((i=0;i<100;i++)); do [[ -b /dev/vda ]] && break; sleep .1; done
mount /dev/vda /newroot
mkdir -p /newroot/{dev,proc,sys}
mount --move /dev /newroot/dev
mount --move /proc /newroot/proc
mount --move /sys /newroot/sys
mount --move /run /newroot/run
exec switch_root /newroot /usr/bin/python3 -u /opt/tundra/init.py
'''
entries = [(d, stat.S_IFDIR | 0o755, b'', 0, 0) for d in sorted(dirs, key=lambda s: (s.count('/'), s))] + entries
entries += [('lib', stat.S_IFLNK | 0o777, b'usr/lib', 0, 0),
('lib64', stat.S_IFLNK | 0o777, b'usr/lib', 0, 0),
('bin', stat.S_IFLNK | 0o777, b'usr/bin', 0, 0),
('init', stat.S_IFREG | 0o755, script.encode(), 0, 0),
('dev/console', stat.S_IFCHR | 0o600, b'', 5, 1)]
(OUT / 'initramfs.cpio.gz').write_bytes(cpio(entries))
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--refresh', action='store_true', help='Refresh shared Vulpes/guest files in an existing staged root')
args = parser.parse_args()
OUT.mkdir(parents=True, exist_ok=True)
FS.mkdir(exist_ok=True)
if args.refresh and not (OUT/'build.json').exists():
raise SystemExit('Run a full build before --refresh')
if not (ROOT/'out/usb-diagnostic/x86_64/init').exists():
subprocess.run([sys.executable,str(ROOT/'tools/build-usb.py')],check=True)
if not args.refresh:
lock = json.loads((ROOT / 'manifests/weston.lock.json').read_text())
archive = ROOT / 'downloads/weston' / lock['url'].rsplit('/', 1)[1]
if not archive.exists():
archive.parent.mkdir(parents=True, exist_ok=True)
with urllib.request.urlopen(lock['url'], timeout=60) as response, archive.with_suffix('.part').open('wb') as output:
shutil.copyfileobj(response, output)
archive.with_suffix('.part').replace(archive)
if sha(archive) != lock['sha256']:
raise RuntimeError('Weston checksum mismatch')
WESTON.mkdir(parents=True, exist_ok=True)
subprocess.run(['tar','--no-same-owner','-xf',str(archive),'-C',str(WESTON)],check=True)
for link, dest in [('bin','usr/bin'), ('sbin','usr/bin'), ('lib','usr/lib'), ('lib64','usr/lib'), ('usr/lib64','lib')]:
p = FS / link
p.parent.mkdir(parents=True, exist_ok=True)
if not p.exists() and not p.is_symlink():
p.symlink_to(dest)
tree(WESTON / 'usr', '/usr', ('vnc-backend.so','rdp-backend.so','pipewire-backend.so','xwayland.so','vulkan-renderer.so'))
for name in ('bash','python3','mount','umount','modprobe','mkdir','sleep','ip','udevadm','dbus-daemon','dbus-run-session','seatd','setpriv','sync','fc-cache','bwrap','unshare'):
copy('/usr/bin/' + name)
copy('/usr/lib/systemd/systemd-udevd')
copy('/usr/bin/sh')
for program in ('at-spi-bus-launcher','at-spi2-registryd','xdg-desktop-portal','xdg-desktop-portal-gtk','xdg-permission-store','xdg-document-portal'):
copy('/usr/lib/'+program)
copy('/usr/lib/libkmod.so.2')
copy('/usr/lib/udev/libinput-device-group')
rules = FS / 'usr/lib/udev/rules.d'
if rules.exists(): shutil.rmtree(rules)
for name in ('60-input-id.rules','60-drm.rules','70-mouse.rules','71-seat.rules','73-seat-late.rules','80-libinput-device-groups.rules','90-libinput-fuzz-override.rules'):
p = Path('/usr/lib/udev/rules.d') / name
if p.exists(): copy(p)
write('/usr/lib/udev/rules.d/50-tundra.rules', 'SUBSYSTEM=="input", GROUP="video", MODE="0660"\nSUBSYSTEM=="drm", GROUP="video", MODE="0660"\nSUBSYSTEM=="tty", KERNEL=="tty[0-9]*", GROUP="video", MODE="0660"\n')
python = Path(sys.base_prefix) / 'lib' / f'python{sys.version_info.major}.{sys.version_info.minor}'
tree(python, str(python), ('site-packages','__pycache__','test','tests','idlelib','tkinter','ensurepip'))
for dirname in ('/usr/share/xdg-desktop-portal','/usr/share/mime','/usr/lib/glycin-loaders','/usr/share/glycin-loaders','/usr/lib/locale/C.utf8','/usr/share/libinput','/usr/share/X11/xkb','/usr/share/glvnd/egl_vendor.d','/usr/share/glib-2.0/schemas','/usr/lib/gdk-pixbuf-2.0','/usr/share/dbus-1','/etc/fonts'):
tree(dirname, dirname)
for name in ('libEGL.so.1','libEGL_mesa.so.0','libGL.so.1','libGLX.so.0','libGLX_mesa.so.0','libGLESv2.so.2','libgbm.so.1','libgtk-3.so.0','libgdk-3.so.0','libnss_dns.so.2','libnss_files.so.2'):
p = Path('/usr/lib') / name
if p.exists(): copy(p)
for name in ('swrast_dri.so','virtio_gpu_dri.so'):
copy('/usr/lib/dri/' + name)
for gallium in Path('/usr/lib').glob('libgallium-*.so'):
copy(gallium)
for font in Path('/usr/share/fonts/TTF').glob('DejaVuSans*.ttf'):
copy(font, deps=False)
for font in (PROJECT / 'gaia/shared').rglob('*.ttf'):
copy(font, '/usr/share/fonts/vulpes/' + font.name, deps=False)
kernel = os.uname().release
shutil.copy2('/boot/vmlinuz-linux', OUT / 'vmlinuz')
initramfs(kernel)
write('/etc/passwd', 'root:x:0:0:root:/root:/usr/bin/bash\nvulpes:x:1000:1000:Vulpes:/home/vulpes:/usr/bin/bash\n')
write('/etc/group', 'root:x:0:\nvideo:x:44:vulpes\nrender:x:992:vulpes\nvulpes:x:1000:\n')
write('/etc/nsswitch.conf', 'passwd: files\ngroup: files\nhosts: files dns\n')
write('/etc/hosts', '127.0.0.1 localhost\n::1 localhost\n')
write('/etc/os-release', 'NAME="Tundra development VM"\nID=tundra\nPRETTY_NAME="Tundra x86_64 prototype"\n')
write('/etc/machine-id', 'a30d62a5b7954ddda12e13ab83aaff52\n')
write('/etc/ld.so.conf', '/usr/lib\n/usr/lib/weston\n')
# Refresh the generated payload as a unit so removed source files stay removed.
for name in ('assets','host','services','adapters','overrides','gaia','tools','tests'):
path=FS/'opt/vulpes'/name
if path.exists(): shutil.rmtree(path)
if (FS/'opt/tundra').exists(): shutil.rmtree(FS/'opt/tundra')
# Only distributable product inputs; never the user's profiles, keys or history.
for name in ('assets','host','services','adapters','overrides'):
tree(PROJECT / name, '/opt/vulpes/' + name, ('__pycache__',))
tree(PROJECT / 'gaia/shared', '/opt/vulpes/gaia/shared', ('test','tests'))
tree(PROJECT / 'gaia/apps/system', '/opt/vulpes/gaia/apps/system', ('test','tests'))
for name in ('engine-lock.json','release.json'):
copy(PROJECT / name, '/opt/vulpes/' + name)
for name in ('serve-gaia.py','project.py','run-host.py','marionette_client.py'):
copy(PROJECT / 'tools' / name, '/opt/vulpes/tools/' + name)
copy(PROJECT / 'tests/ProbeChild.sys.mjs', '/opt/vulpes/tests/ProbeChild.sys.mjs')
engine = json.loads((PROJECT / 'engine-lock.json').read_text())
if not args.refresh:
tree(PROJECT / engine['runtime'], '/opt/vulpes/' + engine['runtime'], ('vulpes','updated','updates'))
tree(ROOT / 'vm', '/opt/tundra', ('__pycache__',))
copy(ROOT/'out/usb-diagnostic/x86_64/init','/opt/tundra/usb-init')
copy(ROOT/'tools/usb_protocol.py','/opt/tundra/usb_protocol.py')
for directory in ('dev','proc','sys','run','tmp','var/log','var/cache/fontconfig','home/vulpes','root'):
(FS / directory).mkdir(parents=True, exist_ok=True)
(FS / 'tmp').chmod(0o1777)
subprocess.run(['/usr/bin/ldconfig','-r',str(FS)], check=True)
print('Writing ext4 image…', flush=True)
image = OUT / 'root.ext4'
with image.open('wb') as f:
f.truncate(4 * 1024**3)
subprocess.run(['fakeroot', '--', sys.executable, str(ROOT/'tools/pack-rootfs.py'), str(FS), str(image)], check=True)
files = {}
for p in sorted(FS.rglob('*')):
if p.is_file() and not p.is_symlink():
files[str(p.relative_to(FS))] = sha(p)
report = {'scope':'x86_64 Linux/Wayland development VM; no Android HAL or Pixel validation',
'gecko':engine['version'], 'kernel':os.uname().release, 'created':time.time(),
'rootImageSha256':sha(image),'network':'loopback only; no virtual NIC', 'renderer':'Weston Pixman',
'files':files, 'initramfsSha256':sha(OUT/'initramfs.cpio.gz'), 'kernelSha256':sha(OUT/'vmlinuz')}
(OUT / 'build.json').write_text(json.dumps(report, indent=2) + '\n')
print(f'Ready: {image} ({len(files)} files recorded)', flush=True)
if __name__ == '__main__': main()

View file

@ -0,0 +1,100 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Read a Linux phone inventory through an already authenticated SSH master."""
import argparse
import ipaddress
import os
from pathlib import Path
import re
import shlex
import subprocess
from tundra import ROOT, now, save
READS = {
'identity': ['id'],
'os-release': ['cat', '/etc/os-release'],
'kernel': ['uname', '-a'],
'cmdline': ['cat', '/proc/cmdline'],
'partitions': ['lsblk', '-b', '-J', '-o', 'NAME,TYPE,SIZE,FSTYPE,PARTLABEL,MOUNTPOINTS'],
'partition-links': ['ls', '-l', '/dev/disk/by-partlabel', '/dev/block/by-name'],
'mounts': ['cat', '/proc/mounts'],
'fstab': ['cat', '/etc/fstab'],
'deviceinfo': ['ls', '-la', '/etc/deviceinfo'],
'boot-files': ['ls', '-l', '/boot'],
'devices': ['ls', '-l', '/dev/dri', '/dev/input', '/dev/video0', '/dev/binder'],
'services': ['systemctl', 'list-units', '--type=service', '--state=running', '--no-pager', '--plain'],
'packages': ['dpkg-query', '-W', '-f', '${binary:Package}\t${Version}\n'],
'android-properties': ['getprop'],
}
def identify(model, compatible, android_device=''):
model = model.replace('\0', '').strip()
tokens = compatible.strip().split('\0')
android_device = android_device.strip()
if model == 'Google Pixel 3a XL' or 'google,bonito' in tokens or android_device == 'bonito':
return 'wrong-device-pixel-3a-xl'
if model == 'Google Pixel 3a' or 'google,sargo' in tokens or android_device == 'sargo':
return 'sargo'
return 'unverified'
def ssh_base(socket, host, user):
# ProxyCommand=false prevents a new connection if the master disappears.
return ['ssh', '-F', '/dev/null', '-S', str(socket),
'-o', 'BatchMode=yes', '-o', 'ProxyCommand=false',
'-o', 'ForwardAgent=no', '-o', 'ClearAllForwardings=yes',
f'{user}@{host}']
def capture(base, command):
try:
result = subprocess.run(base + [shlex.join(command)], capture_output=True,
text=True, errors='replace', timeout=20)
output = result.stdout
if command == READS['packages']:
output = '\n'.join(line for line in output.splitlines() if re.search(
r'android|droidian|hybris|halium|linux-|phosh|phoc|wayland|mesa|firmware|modem|pulse|pipewire',
line, re.I))
return {'command': command, 'returncode': result.returncode,
'stdout': output[:65536], 'stderr': result.stderr[:4096],
'truncated': len(output) > 65536}
except subprocess.TimeoutExpired:
return {'command': command, 'returncode': None, 'error': 'timeout'}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--control-socket', type=Path, required=True)
parser.add_argument('--host', default='10.15.19.82')
parser.add_argument('--user', default='droidian')
args = parser.parse_args()
ipaddress.ip_address(args.host)
if not re.fullmatch(r'[a-z_][a-z0-9_-]*', args.user):
parser.error('Invalid SSH user')
socket = args.control_socket.resolve()
if not socket.is_socket():
parser.error('Open the authenticated SSH master in your terminal first')
os.umask(0o077)
base = ssh_base(socket, args.host, args.user)
subprocess.run(base[:-1] + ['-O', 'check', base[-1]], check=True)
records = {name: capture(base, ['cat', path]) for name, path in (
('model', '/proc/device-tree/model'),
('compatible', '/proc/device-tree/compatible'))}
records['android-device'] = capture(base, ['getprop', 'ro.product.device'])
value = lambda name: records[name].get('stdout', '') if records[name].get('returncode') == 0 else ''
identity = identify(value('model'), value('compatible'), value('android-device'))
if identity == 'sargo':
for name, command in READS.items():
records[name] = capture(base, command)
path = ROOT/'logs/device'/('ssh-'+now().replace(':', '-')+'.json')
save(path, {'recordedAt': now(), 'transport': 'ssh-existing-master',
'identity': identity, 'readOnly': True,
'flashAuthorizedByThisReport': False, 'records': records})
print(f'Inventory: {path}\nIdentity: {identity}')
return 0 if identity == 'sargo' else 2
if __name__ == '__main__':
raise SystemExit(main())

View file

@ -0,0 +1,91 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Read-only sargo inventory. Never reboot, root, unlock, mount, push or flash."""
import argparse
import os
import re
import subprocess
import sys
from tundra import ROOT, now, save
PROPERTIES = (
"ro.product.device", "ro.product.vendor.device", "ro.product.name",
"ro.build.fingerprint", "ro.vendor.build.fingerprint", "ro.build.version.release",
"ro.build.version.security_patch", "ro.boot.slot_suffix", "ro.boot.bootloader",
"ro.boot.verifiedbootstate", "ro.boot.flash.locked", "ro.boot.hardware",
)
READS = {
"os-release": ["cat", "/etc/os-release"],
"kernel": ["uname", "-a"],
"device-model": ["cat", "/proc/device-tree/model"],
"device-compatible": ["cat", "/proc/device-tree/compatible"],
"deviceinfo": ["cat", "/etc/deviceinfo"],
"system-image": ["cat", "/etc/system-image/channel.ini"],
"partitions": ["cat", "/proc/partitions"],
"partition-links": ["ls", "-l", "/dev/disk/by-partlabel"],
"android-partition-links": ["ls", "-l", "/dev/block/by-name"],
"mounts": ["cat", "/proc/mounts"],
}
def capture(command):
try:
p = subprocess.run(command, capture_output=True, text=True, timeout=15)
return {"returncode": p.returncode, "stdout": p.stdout[:65536], "stderr": p.stderr[:4096]}
except subprocess.TimeoutExpired:
return {"returncode": None, "error": "timeout"}
def identify(records):
values = []
for prop in ("ro.product.device", "ro.product.vendor.device"):
result = records.get(prop, {})
if result.get("returncode") == 0:
values.append(result.get("stdout", "").strip())
if "bonito" in values:
return "wrong-device-pixel-3a-xl"
if "sargo" in values:
return "sargo"
# Ubuntu Touch may have no getprop executable. Do not infer from SDM670 alone.
result = records.get("device-model", {})
model = result.get("stdout", "").replace("\0", "").strip() if result.get("returncode") == 0 else ""
if model == "Google Pixel 3a":
return "sargo-model-only"
return "unverified"
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--serial", required=True, help="Explicit adb/fastboot transport serial")
parser.add_argument("--transport", choices=("adb", "fastboot"), default="adb")
args = parser.parse_args()
if not re.fullmatch(r"[A-Za-z0-9_.:-]+", args.serial) or args.serial.startswith("-"):
parser.error("Invalid device serial")
os.umask(0o077)
base = [args.transport, "-s", args.serial]
records = {}
if args.transport == "adb":
state = capture(base + ["get-state"])
if state.get("returncode") != 0 or state.get("stdout", "").strip() != "device":
sys.exit("Selected device is not available/authorized; no device changes made")
for prop in PROPERTIES:
records[prop] = capture(base + ["shell", "getprop", prop])
for name, command in READS.items():
records[name] = capture(base + ["shell", *command])
identity = identify(records)
else:
for name in ("product", "current-slot", "unlocked", "secure", "version-bootloader", "version-baseband"):
records[name] = capture(base + ["getvar", name])
product = records["product"]
text = product.get("stdout", "") + product.get("stderr", "")
identity = "sargo" if product.get("returncode") == 0 and re.search(r"\bproduct:\s*sargo\b", text) else "unverified"
path = ROOT / "logs/device" / (now().replace(":", "-") + ".json")
save(path, {"recordedAt": now(), "transport": args.transport, "identity": identity,
"readOnly": True, "flashAuthorizedByThisReport": False, "records": records})
print(f"Inventory: {path}\nIdentity: {identity}; firmware/partitions still require review.")
return 0 if identity.startswith("sargo") else 2
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,68 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Copy a checked trial into a new user directory, then open its Wayland window."""
import argparse
import hashlib
import importlib.util
import json
from pathlib import Path
import shlex
import subprocess
from datetime import datetime, timezone
ROOT = Path(__file__).resolve().parents[1]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--control-socket', type=Path, required=True)
parser.add_argument('--host', default='10.15.19.82')
parser.add_argument('--user', default='droidian')
args = parser.parse_args()
spec = importlib.util.spec_from_file_location('collector', ROOT/'tools/collect-device-ssh.py')
collector = importlib.util.module_from_spec(spec)
spec.loader.exec_module(collector)
base = collector.ssh_base(args.control_socket.resolve(), args.host, args.user)
subprocess.run(base[:-1]+['-O','check',base[-1]], check=True)
def run(command, **kwargs):
return subprocess.run(base+[shlex.join(command)], check=True, **kwargs)
identity = run(['getprop','ro.product.device'], capture_output=True, text=True).stdout.strip()
if identity != 'sargo':
raise RuntimeError('Expected the previously inventoried sargo device')
archive = ROOT/'out/droidian/vulpes-droidian.tar.gz'
metadata = json.loads(archive.with_suffix('.json').read_text())
with archive.open('rb') as stream:
if hashlib.file_digest(stream,'sha256').hexdigest() != metadata['sha256']:
raise RuntimeError('Local archive hash differs from its manifest')
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
remote = run(['python3','-c',
"from pathlib import Path; p=Path.home()/'vulpes-tundra-tests'/"+repr(stamp)+
"; p.mkdir(parents=True,exist_ok=False); print(p)"],
capture_output=True,text=True).stdout.strip()
with archive.open('rb') as stream:
run(['python3','-c',
"import sys,shutil; f=open(sys.argv[1],'xb'); shutil.copyfileobj(sys.stdin.buffer,f); f.close()",
remote+'/payload.tar.gz'], stdin=stream, timeout=300)
code = '''import hashlib,json,pathlib,subprocess,sys,tarfile
p=pathlib.Path(sys.argv[1]); archive=p/'payload.tar.gz'
with archive.open('rb') as stream: digest=hashlib.file_digest(stream,'sha256').hexdigest()
if digest != sys.argv[2]: raise RuntimeError('Transfer hash mismatch')
product=p/'product'; product.mkdir()
with tarfile.open(archive) as package: package.extractall(product,filter='data')
log=(p/'session.log').open('w')
proc=subprocess.Popen(['python3',str(product/'trial/session.py')],stdin=subprocess.DEVNULL,
stdout=log,stderr=subprocess.STDOUT,start_new_session=True)
print(json.dumps({'directory':str(p),'pid':proc.pid,'sha256':digest}))
'''
result = json.loads(run(['python3','-c',code,remote,metadata['sha256']],
capture_output=True,text=True,timeout=900).stdout)
result['engine'] = metadata['engine']
result['createdAt'] = stamp
path = ROOT/'logs/device/droidian-deployment.json'
path.write_text(json.dumps(result,indent=2)+'\n')
path.chmod(0o600)
print(json.dumps(result,indent=2))
if __name__ == '__main__':
main()

View file

@ -0,0 +1,129 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Operate only the last isolated trial, using the existing authenticated SSH master."""
import argparse
import importlib.util
import json
from pathlib import Path
import shlex
import subprocess
ROOT = Path(__file__).resolve().parents[1]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['status', 'collect', 'capture', 'restart', 'stop'])
parser.add_argument('--hybris-egl', action='store_true')
parser.add_argument('--software', action='store_true')
parser.add_argument('--headless', action='store_true')
parser.add_argument('--no-forkserver', action='store_true')
parser.add_argument('--trace-navigation', action='store_true')
parser.add_argument('--control-socket', type=Path, default=ROOT/'logs/device/ssh-control')
args = parser.parse_args()
if not args.control_socket.is_socket():
parser.error('SSH session unavailable. Reopen the authenticated master described in docs/SARGO.md.')
spec = importlib.util.spec_from_file_location('collector', ROOT/'tools/collect-device-ssh.py')
collector = importlib.util.module_from_spec(spec)
spec.loader.exec_module(collector)
base = collector.ssh_base(args.control_socket.resolve(), '10.15.19.82', 'droidian')
master = subprocess.run(base[:-1]+['-O', 'check', base[-1]], capture_output=True)
if master.returncode:
parser.error('SSH master disconnected. Reopen it before operating the trial.')
deployment = json.loads((ROOT/'logs/device/droidian-deployment.json').read_text())
remote = deployment['directory']
def run(code, *extra, **kwargs):
return subprocess.run(base+[shlex.join(['python3','-c',code,remote,*extra])],
check=True, timeout=180, **kwargs)
if args.action in ('stop', 'restart'):
run(r'''import os,pathlib,signal,sys,time
p=pathlib.Path(sys.argv[1]); product=p/'product'; pidfile=product/'logs/session.pid'
if pidfile.exists():
pid=int(pidfile.read_text()); proc=pathlib.Path('/proc')/str(pid)
if proc.exists():
command=(proc/'cmdline').read_bytes().split(b'\0')
if str(product/'trial/session.py').encode() not in command: raise RuntimeError('PID no longer belongs to trial')
os.kill(pid,signal.SIGTERM)
for _ in range(1500):
if not pidfile.exists(): break
time.sleep(.1)
else: raise RuntimeError('Session did not stop; left untouched')
''')
if args.action == 'restart':
for name in ('session.py', 'probe.sys.mjs'):
run("import pathlib,sys; (pathlib.Path(sys.argv[1])/'product/trial'/sys.argv[2]).write_bytes(sys.stdin.buffer.read())",
name, input=(ROOT/'droidian'/name).read_bytes())
if args.hybris_egl:
subprocess.run(['python3',str(ROOT/'tools/build-droidian-graphics.py')],check=True)
for name in ('libEGL_vulpes_hybris.so','manifest.json'):
run("import pathlib,sys;p=pathlib.Path(sys.argv[1])/'product/trial/graphics';p.mkdir(exist_ok=True);(p/sys.argv[2]).write_bytes(sys.stdin.buffer.read())",
name,input=(ROOT/'out/droidian-graphics'/name).read_bytes())
run('''import pathlib,shutil,subprocess,sys,time
p=pathlib.Path(sys.argv[1]); product=p/'product'; logs=product/'logs'
backup=logs/('attempt-'+str(time.time_ns())); backup.mkdir()
for name in ('gecko.log','server.log','test.json','home.png'):
f=logs/name
if f.exists(): shutil.move(f,backup/name)
for f in logs.glob('navigation.log*'): shutil.move(f,backup/f.name)
if (p/'session.log').exists(): shutil.move(p/'session.log',backup/'session.log')
with (p/'session.log').open('a') as output:
proc=subprocess.Popen(['python3',str(product/'trial/session.py'),*sys.argv[2:]],
stdin=subprocess.DEVNULL,stdout=output,stderr=subprocess.STDOUT,start_new_session=True)
print('Started trial supervisor',proc.pid)
''', *(['--software'] if args.software else []), *(['--headless'] if args.headless else []),
*(['--no-forkserver'] if args.no_forkserver else []),
*(['--trace-navigation'] if args.trace_navigation else []),
*(['--hybris-egl'] if args.hybris_egl else []))
if args.action == 'capture':
result=run('''import os,pathlib,subprocess,sys,time
p=pathlib.Path(sys.argv[1])/'product/logs'; shot=p/('physical-'+str(time.time_ns())+'.png')
env=dict(os.environ,DBUS_SESSION_BUS_ADDRESS='unix:path=/run/user/'+str(os.getuid())+'/bus')
result=subprocess.run(['gdbus','call','--session','--dest','org.gnome.Shell.Screenshot',
'--object-path','/org/gnome/Shell/Screenshot','--method',
'org.gnome.Shell.Screenshot.Screenshot','false','false',str(shot)],
env=env,capture_output=True,text=True,check=True,timeout=15)
if not result.stdout.startswith('(true,'): raise RuntimeError(result.stdout)
sys.stdout.buffer.write(shot.read_bytes())
''',capture_output=True)
target=ROOT/'logs/device/droidian-trial/physical-display.png'
target.parent.mkdir(parents=True,exist_ok=True)
target.write_bytes(result.stdout)
print(target)
if args.action == 'status':
run('''import pathlib,sys
p=pathlib.Path(sys.argv[1])
for name in ('session.log','product/logs/gecko.log','product/logs/test.json'):
f=p/name; print(name, f.read_text(errors='replace')[-12000:] if f.exists() else 'pending')
''')
if args.action == 'collect':
target=ROOT/'logs/device/droidian-trial'
target.mkdir(exist_ok=True)
for name in ('gecko.log','server.log','test.json','home.png'):
result=run("import pathlib,sys; f=pathlib.Path(sys.argv[1])/'product/logs'/sys.argv[2]; sys.stdout.buffer.write(f.read_bytes() if f.exists() else b'')",
name,capture_output=True)
if result.stdout:
(target/name).write_bytes(result.stdout)
else:
(target/name).unlink(missing_ok=True)
result=run(r'''import json,pathlib,platform,sys
p=pathlib.Path(sys.argv[1]); product=p/'product'; logs=product/'logs'
report={'kernel':platform.release(),'architecture':platform.machine(),'attempts':{},'processes':[]}
for folder in sorted(logs.glob('attempt-*')):
report['attempts'][folder.name]={f.name:f.read_text(errors='replace')[-80000:]
for f in folder.iterdir() if f.name in ('gecko.log','test.json')}
for proc in pathlib.Path('/proc').glob('[0-9]*'):
try:
cmd=(proc/'cmdline').read_bytes().split(b'\0')
if cmd and str(product/'engines').encode() in cmd[0]:
status={k:v.strip() for k,v in (line.split(':',1) for line in (proc/'status').read_text().splitlines() if ':' in line)
if k in ('Name','State','VmRSS','Seccomp','NoNewPrivs')}
report['processes'].append({'pid':proc.name,'status':status,'wchan':(proc/'wchan').read_text()})
except OSError: pass
print(json.dumps(report,indent=2))
''',capture_output=True)
(target/'diagnostics.json').write_bytes(result.stdout)
print(target)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,128 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Mark the installed Sargo slot successful after current-boot health checks.
Uses Debian's Qualcomm boot-control utility. Never switches slots or writes a
boot image. GPT metadata is backed up before its success bits are updated.
"""
import argparse
import base64
import hashlib
import io
import json
from pathlib import Path
import subprocess
import tarfile
import tempfile
import urllib.request
ROOT = Path(__file__).resolve().parents[1]
PACKAGE_URL = 'https://deb.debian.org/debian/pool/main/q/qbootctl/qbootctl_0.2.2-1_arm64.deb'
PACKAGE_SHA = '76b9be97107f14a79641bc274a00010aade3362b5099df5ae8b85603cbb6eb22'
SUCCESS = 1 << 54
def verify_transition(before, after):
changed = []
for copy in ('primary', 'secondary'):
if 'header' in before[copy]:
headers = [bytearray(base64.b64decode(v[copy]['header'])) for v in (before, after)]
for h in headers:
h[16:20] = b'\0'*4
h[88:92] = b'\0'*4
if headers[0] != headers[1]:
raise ValueError('GPT header fields changed')
a = base64.b64decode(before[copy]['table'])
b = base64.b64decode(after[copy]['table'])
if len(a) != len(b):
raise ValueError('Partition table length changed')
expected = bytearray(a)
for label, part in before[copy]['partitions'].items():
offset = (part['index'] - 1) * 128
if a[offset:offset+128] == b[offset:offset+128]:
continue
if not label.endswith('_a') or label[:-1]+'b' not in before[copy]['partitions']:
raise ValueError('Unexpected partition changed: '+label)
expected[offset+54] |= 0x40
if copy == 'primary': changed.append(label)
if bytes(expected) != b:
raise ValueError('A field other than a success bit changed')
if not after['primary']['partitions']['boot_a']['attributes'] & SUCCESS:
raise ValueError('Slot A was not marked successful')
return changed
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build', type=Path, required=True)
parser.add_argument('--mark-successful', action='store_true')
args = parser.parse_args()
folder = args.build.resolve()
build = json.loads((folder/'build.json').read_text())
install = json.loads((folder/'install-report.json').read_text())
if not install['completed'] or install['imageSha256'] != build['imageSha256']:
raise ValueError('A successful installation report is required')
ssh = ['ssh', '-F', '/dev/null', '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes',
'-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=yes',
'-o', 'HostKeyAlgorithms=+ssh-rsa', '-o', 'UserKnownHostsFile='+str(folder/'known_hosts'),
'-i', str(folder/'client-key'), '-p', '2222', 'root@10.15.19.82']
def remote(command, data=None):
return subprocess.run(ssh+[command], input=data, capture_output=True,
check=True, timeout=45).stdout
config = {'serialSha256':build['serialSha256'], 'imageSha256':build['imageSha256'],
'bytes':(folder/build['image']).stat().st_size}
inspect = (ROOT/'tools/read-sargo-gpt.py').read_text()
health = '''
from pathlib import Path
import hashlib,json,subprocess
cmdline = dict(x.split('=',1) for x in Path('/proc/cmdline').read_text().split() if '=' in x)
assert cmdline.get('androidboot.slot_suffix') == '_a'
assert hashlib.sha256(cmdline['androidboot.serialno'].encode()).hexdigest() == config['serialSha256']
with open('/dev/disk/by-partlabel/boot_a','rb') as f:
assert hashlib.sha256(f.read(config['bytes'])).hexdigest() == config['imageSha256']
gaia = json.loads(Path('/opt/vulpes/logs/test.json').read_text())
assert gaia['passed'] and all(gaia['checks'].values())
assert gaia['bootId'] == Path('/proc/sys/kernel/random/boot_id').read_text().strip()
for units in [('tundra-hal','tundra-display','tundra-radio')]:
subprocess.run(['systemctl','is-active','--quiet',*units],check=True)
assert not subprocess.check_output(['systemctl','--failed','--no-legend','--no-pager']).strip()
'''
def snapshot():
code = 'config='+repr(config)+'\n'+health+'\n'+inspect
return json.loads(remote('chroot /proc/1/root /usr/bin/python3 -', code.encode()))
before = snapshot()
attr = before['primary']['partitions']['boot_a']['attributes']
if not attr & (1 << 50) or attr & (1 << 55):
raise ValueError('Slot A must be active and bootable')
print('Installed image, current Gaia boot, services and mirrored GPT checked.')
if not args.mark_successful:
print('Review only. Successful:', bool(attr & SUCCESS)); return
from datetime import datetime, timezone
evidence = folder/('slot-finalization-'+datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ'))
evidence.mkdir(mode=0o700)
(evidence/'gpt-before.json').write_text(json.dumps(before,indent=2)+'\n')
if not attr & SUCCESS:
package = urllib.request.urlopen(PACKAGE_URL, timeout=30).read()
if hashlib.sha256(package).hexdigest() != PACKAGE_SHA:
raise ValueError('qbootctl package hash mismatch')
with tempfile.TemporaryDirectory() as directory:
deb = Path(directory)/'qbootctl.deb'; deb.write_bytes(package)
data = subprocess.check_output(['ar','p',str(deb),'data.tar.xz'])
with tarfile.open(fileobj=io.BytesIO(data),mode='r:xz') as archive:
binary = archive.extractfile('./usr/bin/qbootctl').read()
remote('cat > /proc/1/root/run/tundra-qbootctl && chmod 700 /proc/1/root/run/tundra-qbootctl', binary)
try:
output = remote('chroot /proc/1/root /run/tundra-qbootctl -m a')
(evidence/'qbootctl.txt').write_bytes(output)
finally:
remote('rm -f /proc/1/root/run/tundra-qbootctl')
after = snapshot()
(evidence/'gpt-after.json').write_text(json.dumps(after,indent=2)+'\n')
changed = verify_transition(before, after)
(evidence/'result.json').write_text(json.dumps({'passed':True,'bootId':after['bootId'],
'imageSha256':build['imageSha256'],'packageSha256':PACKAGE_SHA,
'metadataChanged':changed,'partitionsFlashed':[]},indent=2)+'\n')
print('Slot A marked successful; metadata checked:', evidence)
if __name__ == '__main__': main()

View file

@ -0,0 +1,93 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Read readiness, never dial, send messages, enter PINs or open camera nodes."""
import json
from pathlib import Path
import subprocess
import time
def command(args):
try:
result = subprocess.run(args, capture_output=True, text=True, timeout=12)
if result.returncode:
return None
return result.stdout
except (OSError, subprocess.TimeoutExpired):
return None
def bus(*args):
text = command(['busctl', '--system', '--json=short', *args])
try:
return json.loads(text)['data'] if text is not None else None
except (ValueError, KeyError, TypeError):
return None
def properties(interface):
data = bus('call', 'org.ofono', '/ril_0', interface, 'GetProperties')
if isinstance(data, list) and len(data) == 1:
data = data[0]
return data if isinstance(data, dict) else {}
def selected(values, names):
# GetProperties returns identifiers too: none are copied into the report.
return {key: values[key].get('data') for key in names
if key in values and isinstance(values[key], dict)}
def inspect():
native = 'tundra.rescue=1' in Path('/proc/cmdline').read_text()
report = {'recordedAtUnix': time.time(), 'environment': 'tundra' if native else 'reference',
'scope': 'Read-only readiness, not functional call/SMS/audio/camera qualification',
'services': {}}
for service in ('tundra-hal', 'tundra-wifi', 'NetworkManager', 'ofono', 'ModemManager'):
report['services'][service] = (command(['systemctl', 'is-active', service]) or 'inactive').strip()
modem = selected(properties('org.ofono.Modem'), ('Powered', 'Online', 'Interfaces'))
sim = selected(properties('org.ofono.SimManager'), ('Present', 'PinRequired', 'LockedPins'))
registration = selected(properties('org.ofono.NetworkRegistration'), ('Status', 'Technology', 'Strength'))
interfaces = modem.get('Interfaces') or []
report['telephony'] = {'modem': modem, 'sim': sim, 'registration': registration,
'voiceInterface': 'org.ofono.VoiceCallManager' in interfaces,
'smsInterface': 'org.ofono.MessageManager' in interfaces,
'outgoingOperationsPerformed': False}
report['audio'] = {}
# Run as the session user for access to their PulseAudio server.
for kind in ('sinks', 'sources'):
raw = command(['pactl', '--format=json', 'list', kind])
try:
items = json.loads(raw) if raw else None
if items is None:
short = command(['pactl', 'list', 'short', kind])
items = [] if short is not None else None
for line in (short or '').splitlines():
fields = line.split('\t')
if len(fields) >= 5:
items.append({'name': fields[1], 'state': fields[-1],
'driver': fields[2]})
if items is None:
report['audio'][kind] = None
continue
report['audio'][kind] = [{key: item.get(key) for key in
('name', 'state', 'mute', 'active_port', 'driver')}
for item in items]
except (TypeError, ValueError):
report['audio'][kind] = []
report['camera'] = {
'halProbeOnly': True,
'applicationInstalled': Path('/usr/bin/droidian-camera').exists(),
'captureTested': False,
}
report['battery'] = {}
for name in ('capacity', 'status'):
try:
report['battery'][name] = Path('/sys/class/power_supply/battery', name).read_text().strip()
except OSError:
pass
return report
if __name__ == '__main__':
print(json.dumps(inspect(), indent=2))

View file

@ -0,0 +1,70 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Inspect a running native RAM trial. Execute inside its main root as root."""
import json
import os
from pathlib import Path
import subprocess
import time
def inspect():
if 'tundra.rescue=1' not in Path('/proc/cmdline').read_text():
raise RuntimeError('This inspector is for a native RAM trial')
result={'recordedAtUnix':time.time(),'uptime':Path('/proc/uptime').read_text().strip()}
commands={
'failedUnits':['systemctl','--failed','--no-legend','--no-pager'],
'activeUnits':['systemctl','is-active','tundra-hal','tundra-display','systemd-logind','user@32011'],
'displayRestarts':['systemctl','show','tundra-display','-p','NRestarts','--value'],
'kernel':['uname','-r'],
'halPid':['lxc-info','-n','tundra-hal','-pH'],
}
for name,command in commands.items():
process=subprocess.run(command,capture_output=True,text=True,timeout=10)
result[name]={'code':process.returncode,'stdout':process.stdout,'stderr':process.stderr}
kernel = subprocess.run(['dmesg'], capture_output=True, text=True, timeout=10)
result['kernelFaults'] = [line for line in kernel.stdout.splitlines()
if any(marker in line for marker in
('Internal error: Oops', 'Kernel panic', 'Kernel BUG', 'BUG:'))]
result['memoryKills'] = [line for line in kernel.stdout.splitlines()
if any(marker in line for marker in
('lowmemorykiller: Killing', 'Out of memory: Kill', 'oom-kill:', 'Killed process'))]
result['kernelLogReadable'] = kernel.returncode == 0
result['pid1']=Path('/proc/1/cmdline').read_text().replace('\0',' ')
result['identity']=Path('/etc/os-release').read_text()
result['mounts']=Path('/proc/1/mounts').read_text()
result['memory']=Path('/proc/meminfo').read_text()
pid=result['halPid']['stdout'].strip()
result['halCgroups']=Path('/proc',pid,'cgroup').read_text() if pid.isdigit() else None
result['halMemoryLimits']={str(p):p.read_text().strip() for p in Path('/sys/fs/cgroup').rglob('memory.max') if 'lxc.payload.tundra-hal' in str(p)}
result['gaia']=json.loads(Path('/opt/vulpes/logs/test.json').read_text())
processes=[]
for path in Path('/proc').glob('[0-9]*/cmdline'):
try:
command=path.read_text().replace('\0',' ').strip()
if not command:continue
status=dict(line.split(':',1) for line in (path.parent/'status').read_text().splitlines() if ':' in line)
processes.append({'pid':int(path.parent.name),'command':command,
'uid':status.get('Uid','').strip(),'seccomp':status.get('Seccomp','').strip(),
'capabilities':status.get('CapEff','').strip()})
except (OSError,ProcessLookupError):continue
result['processes']=processes
result['checks']={
'noKernelFaults':result['kernelLogReadable'] and not result['kernelFaults'],
'noMemoryKills':result['kernelLogReadable'] and not result['memoryKills'],
'tundraPid1':'--unit=tundra.target' in result['pid1'],
'noFailedUnits':result['failedUnits']['code']==0 and not result['failedUnits']['stdout'].strip(),
'servicesActive':result['activeUnits']['code']==0 and result['activeUnits']['stdout'].split()==['active']*4,
'noDisplayRestart':result['displayRestarts']['stdout'].strip()=='0',
'gaia':result['gaia']['passed'],
'halLegacyDeviceGuard':bool(result['halCgroups'] and ':devices:/hal' in result['halCgroups']),
'halMemoryBounded':'536870912' in result['halMemoryLimits'].values(),
'noAndroidAppRuntime':not any(p['command'].split()[0].rsplit('/',1)[-1] in ('zygote','zygote64','app_process','app_process64','phosh') for p in processes),
}
return result
if __name__=='__main__':
result=inspect()
print(json.dumps(result,indent=2))
raise SystemExit(0 if all(result['checks'].values()) else 1)

View file

@ -0,0 +1,94 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Review, install or restore a qualified private Sargo boot image.
The system and data files must already be staged and tested on the inventoried
phone. Only boot_a can be written. No userdata formatting, slot switch or erase.
"""
import argparse
import importlib.util
import json
import os
from pathlib import Path
import subprocess
from tundra import ROOT, digest, now, save
def load(name, path):
spec=importlib.util.spec_from_file_location(name,path)
value=importlib.util.module_from_spec(spec);spec.loader.exec_module(value)
return value
def checked_build(folder):
folder=Path(folder).resolve()
build=json.loads((folder/'build.json').read_text())
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')
or not build.get('persistentState') or build.get('watchdogSeconds') is not None
or build.get('interactiveDiagnostics')):
raise ValueError('Expected a qualified persistent image without interactive tests or a deadline')
image=(folder/build['image']).resolve();image.relative_to(folder)
if digest(image)!=build['imageSha256'] or qualification.get('imageSha256')!=build['imageSha256']:
raise ValueError('Boot image provenance mismatch')
evidence=qualification.get('evidenceSha256')
if not evidence:raise ValueError('Qualification evidence missing')
for name,expected in evidence.items():
path=(folder/name).resolve();path.relative_to(folder)
if digest(path)!=expected:raise ValueError('Qualification evidence changed: '+name)
persistence=qualification.get('persistence',{})
if not (persistence.get('passed') and persistence.get('distinctBoots')
and persistence.get('sameGaiaSetting') and persistence.get('restored')):
raise ValueError('A real persistence round trip is required')
load('rescue_check',ROOT/'tools/test-rescue-boot.py').verify_sources(folder,build)
return build,qualification,image
def flash_boot_a(serial, image):
# Keep the write surface separate from the RAM boot tools.
subprocess.run(['fastboot','-s',serial,'flash','boot_a',str(image)],check=True,timeout=90)
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build',type=Path,required=True)
action=parser.add_mutually_exclusive_group()
action.add_argument('--flash',action='store_true',help='Install this boot image in boot_a; does not reboot automatically')
action.add_argument('--restore',action='store_true',help='Restore the verified original boot_a backup; retain Tundra data')
args=parser.parse_args();os.umask(0o077)
boot=load('sargo_boot',ROOT/'tools/test-sargo-boot.py')
plan,_=boot.checked_plan()
if args.restore:
# Recovery must still work if the candidate or its proof was damaged.
image=Path(plan['backupDirectory'])/'boot_a.img'
build={'serialSha256':plan['serialSha256']}
else:
build,qualification,image=checked_build(args.build)
if plan['serialSha256']!=build['serialSha256']:raise ValueError('Build targets another phone')
print('Private Sargo installation plan')
print('Boot image:',image)
if not args.restore:
print('System file: /tundra/images/'+build['systemImage']['imageSha256']+'.ext4')
print('Data file: /tundra/data/'+build['persistentState']['uuid']+'.ext4')
print('Only boot_a may be written. Existing files, userdata and slot B are retained.')
if not (args.flash or args.restore):
print('Review only. Use --flash or --restore explicitly with the inventoried phone in Fastboot.')
return
serial=boot.find_fastboot(build['serialSha256'],15)
identity=boot.check_device(serial,build['serialSha256'])
report={'recordedAt':now(),'operation':'restore' if args.restore else 'install',
'partition':'boot_a','imageSha256':digest(image),'identity':identity,
'erasesSent':False,'slotChanged':False,'completed':False}
path=args.build/('restore-report.json' if args.restore else 'install-report.json')
save(path,report)
flash_boot_a(serial,image)
report['completed']=True;save(path,report)
print('boot_a written. Select Start in Fastboot to boot. No other partition was flashed.')
if not args.restore:
print('After Gaia passes startup checks, finalize the A/B success flag:')
print(' python3 tundra/tools/finalize-sargo-slot.py --build '+str(args.build)+' --mark-successful')
print('Do not repeat test reboots before this step: the bootloader has a limited retry count.')
if __name__=='__main__':main()

View file

@ -0,0 +1,14 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Normalize guest ownership under fakeroot, then create ext4 without host root."""
import os
from pathlib import Path
import subprocess
import sys
if not os.environ.get('FAKEROOTKEY'):
raise SystemExit('Run through fakeroot; never chown the staging tree as real root.')
root=Path(sys.argv[1])
for base, dirs, files in os.walk(root):
os.lchown(base,0,0)
for name in dirs+files: os.lchown(Path(base)/name,0,0)
subprocess.run(['mke2fs','-q','-t','ext4','-F','-d',str(root),sys.argv[2]],check=True)

View file

@ -0,0 +1,38 @@
# SPDX-License-Identifier: MPL-2.0
"""Describe the dedicated ext4 data file used by a private Sargo installation."""
import hashlib
import json
import re
import uuid
from pathlib import Path
FEATURES = 'none,has_journal,ext_attr,dir_index,filetype,extent,sparse_super,large_file,uninit_bg,dir_nlink,extra_isize'
def digest(path):
with Path(path).open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def validate_manifest(path):
path = Path(path).resolve()
value = json.loads(path.read_text())
if value.get('schema') != 1 or value.get('device') != 'sargo':
raise ValueError('Expected a Sargo data-image manifest')
identifier = value.get('uuid')
if not isinstance(identifier, str) or str(uuid.UUID(identifier)) != identifier:
raise ValueError('Invalid data filesystem UUID')
if value.get('image') != identifier+'.ext4':
raise ValueError('Data image filename does not match its UUID')
size = value.get('bytes')
if type(size) is not int or not 512*1024**2 <= size <= 8*1024**3 or size % 4096:
raise ValueError('Invalid data image size')
for name in ('imageSha256', 'systemImageSha256'):
if not re.fullmatch('[a-f0-9]{64}', str(value.get(name, ''))):
raise ValueError('Invalid '+name)
image = path.parent/value['image']
if image.is_symlink() or image.stat().st_size != size or digest(image) != value['imageSha256']:
raise ValueError('Initial data image changed')
if value.get('filesystemCheckPassed') is not True:
raise ValueError('Data filesystem has not passed verification')
return value

View file

@ -0,0 +1,77 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Stage the common Gaia host and a checked ARM64 engine for a Droidian trial."""
import hashlib
import importlib.util
import json
from pathlib import Path
import shutil
import tarfile
import subprocess
import sys
TUNDRA = Path(__file__).resolve().parents[1]
PROJECT = TUNDRA.parent
STAGE = TUNDRA / 'out/droidian/product'
def main():
STAGE.mkdir(parents=True, exist_ok=True)
for name in ('downloads', 'engines'):
(STAGE/name).mkdir(exist_ok=True)
spec = importlib.util.spec_from_file_location('fetch', PROJECT/'tools/fetch-engine.py')
fetch = importlib.util.module_from_spec(spec)
spec.loader.exec_module(fetch)
fetch.ROOT = STAGE
lock = json.loads((PROJECT/'engine-lock.json').read_text())
# Desktop qualification does not qualify an ARM64 runtime on Droidian.
for field in ('qualificationReport', 'gaiaBoots'):
lock.pop(field, None)
version = lock['version']
base = f'https://archive.mozilla.org/pub/firefox/releases/{version}/'
relative = f'linux-aarch64/en-US/firefox-{version}.tar.xz'
sums = fetch.read_url(base+'SHA512SUMS').decode()
expected = next(line.split()[0] for line in sums.splitlines()
if len(line.split()) == 2 and line.split()[1] == relative)
lock.update(archive=base+relative, sha512=expected,
checksumSource=base+'SHA512SUMS', architecture='aarch64')
(STAGE/'downloads/SHA512SUMS').write_text(sums)
fetch.install(lock)
(STAGE/'engines'/version/'engine.json').write_text(json.dumps(lock, indent=2)+'\n')
for name in ('assets', 'host', 'services', 'adapters', 'overrides',
'gaia/shared', 'gaia/apps/system'):
target = STAGE/name
if target.exists():
shutil.rmtree(target)
shutil.copytree(PROJECT/name, target,
ignore=shutil.ignore_patterns('__pycache__', 'test', 'tests'))
for name in ('tools', 'tests'):
(STAGE/name).mkdir(exist_ok=True)
for name in ('serve-gaia.py', 'project.py', 'run-host.py'):
shutil.copy2(PROJECT/'tools'/name, STAGE/'tools'/name)
shutil.copy2(PROJECT/'tests/ProbeChild.sys.mjs', STAGE/'tests/ProbeChild.sys.mjs')
shutil.copy2(PROJECT/'release.json', STAGE/'release.json')
(STAGE/'engine-lock.json').write_text(json.dumps(lock, indent=2)+'\n')
if (STAGE/'trial').exists():
shutil.rmtree(STAGE/'trial')
shutil.copytree(TUNDRA/'droidian', STAGE/'trial',
ignore=shutil.ignore_patterns('__pycache__'))
subprocess.run([sys.executable,str(TUNDRA/'tools/build-droidian-graphics.py')],check=True)
for name in ('libEGL_vulpes_hybris.so','manifest.json'):
shutil.copy2(TUNDRA/'out/droidian-graphics'/name,STAGE/'trial/graphics'/name)
archive = STAGE.parent/'vulpes-droidian.tar.gz'
with tarfile.open(archive, 'w:gz', compresslevel=1) as tar:
for item in sorted(STAGE.iterdir()):
if item.name != 'downloads':
tar.add(item, arcname=item.name)
with archive.open('rb') as stream:
digest = hashlib.file_digest(stream, 'sha256').hexdigest()
(archive.with_suffix('.json')).write_text(json.dumps({
'archive': archive.name, 'sha256': digest, 'engine': lock,
'scope': 'Unprivileged trial on the existing Droidian Wayland session; no boot image.'
}, indent=2)+'\n')
print(archive, digest, flush=True)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,82 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Compare raw boot backups to the installed-kernel diagnostic, without device writes."""
import argparse
import hashlib
import importlib.util
import json
from pathlib import Path
import shlex
import subprocess
import sys
import tempfile
from boot_backup import verify
from tundra import ROOT, checked_source, digest, now, save
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--backup', required=True, type=Path)
args = parser.parse_args()
backup = args.backup.resolve()
verify(backup)
spec = importlib.util.spec_from_file_location('collector', ROOT/'tools/collect-device-ssh.py')
collector = importlib.util.module_from_spec(spec)
spec.loader.exec_module(collector)
ssh = collector.ssh_base(ROOT/'logs/device/ssh-control', '10.15.19.82', 'droidian')
code = """import json,subprocess
def prop(key): return subprocess.check_output(['getprop',key],text=True).strip()
print(json.dumps({k:prop(k) for k in ('ro.product.device','ro.boot.slot_suffix','ro.boot.serialno')}))
"""
device = json.loads(subprocess.check_output(ssh+[shlex.join(['python3','-c',code])], timeout=20))
if device['ro.product.device'] != 'sargo' or device['ro.boot.slot_suffix'] != '_a' or not device['ro.boot.serialno']:
raise ValueError('Expected the inventoried Sargo running slot A')
build = json.loads((ROOT/'out/sargo-raw-diagnostic/build.json').read_text())
if build.get('rawBackupManifestSha256') != digest(backup/'manifest.json'):
raise ValueError('Diagnostic was not built from this raw backup')
slots = {}
with tempfile.TemporaryDirectory() as temp:
for slot in ('a','b'):
out = Path(temp)/slot
result = subprocess.check_output([sys.executable,
str(checked_source('aosp-mkbootimg')/'unpack_bootimg.py'),
'--boot_img',str(backup/f'boot_{slot}.img'),'--out',str(out),
'--format=mkbootimg'], text=True)
args = shlex.split(result)
fields = dict(zip(args[::2],args[1::2]))
slots[slot] = {'kernelSha256':digest(out/'kernel'),
'ramdiskSha256':digest(out/'ramdisk'),
'header':{k:v for k,v in fields.items() if k not in ('--kernel','--ramdisk','--dtb','--cmdline')},
'matchesDiagnosticKernel':digest(out/'kernel') == build['kernelSha256']}
if slot == 'a':
for key,value in slots[slot]['header'].items():
if build['header'].get(key) != value:
raise ValueError('Slot A header differs from the diagnostic: '+key)
if build['header']['--cmdline'] != fields['--cmdline']+' rdinit=/init tundra.usb=1':
raise ValueError('Diagnostic command line differs from the inventoried boot')
if not slots['a']['matchesDiagnosticKernel']:
raise ValueError('Slot A kernel differs from the diagnostic')
image = ROOT/'out/sargo-raw-diagnostic'/build['file']
if digest(image) != build['imageSha256']:
raise ValueError('Diagnostic image changed')
report = {'recordedAt':now(),'device':'sargo','activeSlot':'a',
'serialSha256':hashlib.sha256(device['ro.boot.serialno'].encode()).hexdigest(),
'backupDirectory':str(backup),'backupManifestSha256':digest(backup/'manifest.json'),
'slots':slots,'diagnosticImage':str(image.relative_to(ROOT)),
'diagnosticImageSha256':digest(image),'diagnosticBuildSha256':digest(image.parent/'build.json'),
'rawBackupVerified':True,'restorationTested':False,'hardwareBootTested':False,
'flashReady':False}
path = ROOT/'logs/device/boot-backup-qualification.json'
save(path,report)
validation = json.loads((ROOT/'VALIDATION.json').read_text())
validation['deviceInventory']['rawPartitionsBackedUp'] = True
validation['bootBackup'] = {'report':str(path.relative_to(ROOT)),
'reportSha256':digest(path),'verified':True,'restorationTested':False}
save(ROOT/'VALIDATION.json',validation)
print('Six partitions verified; slot A kernel and header match the diagnostic.')
print('Slot B has different contents and is not a qualified fallback.')
print(path)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,90 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Run inside a native RAM boot; accept a temporary Wi-Fi profile on stdin.
The credential stays in /run, is removed after activation, and is never included
in the JSON report. This qualifies the system path, not Gaia connection controls.
"""
import json
import ipaddress
import os
from pathlib import Path
import socket
import subprocess
import sys
import time
import uuid
def nm(*args, timeout=45):
return subprocess.run(['nmcli', '--wait', '35', *args], capture_output=True,
text=True, timeout=timeout, env={**os.environ, 'LC_ALL':'C'})
def main():
if os.getuid() or 'tundra.rescue=1' not in Path('/proc/cmdline').read_text():
raise RuntimeError('Run as root inside the temporary native system')
profile = json.loads(sys.stdin.read(16384))
ssid = profile['ssid']
if not isinstance(ssid, str) or not 1 <= len(ssid.encode()) <= 32:
raise ValueError('Invalid SSID')
security = profile['keyManagement']
if security not in ('', '--', 'wpa-psk', 'sae'):
raise ValueError('Unsupported authentication')
password = profile.get('psk', '')
if not isinstance(password, str) or any(c in password for c in '\r\n\x00'):
raise ValueError('Invalid password')
report = {'scope':'Native WLAN scan, ephemeral connection and DNS; no Gaia controls', 'checks':{}, 'addressFamilies':{}}
uid = str(uuid.uuid4())
target = Path('/run/NetworkManager/system-connections/tundra-network-check.nmconnection')
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
text = f'[connection]\nid=Tundra temporary network check\nuuid={uid}\ntype=wifi\ninterface-name=wlan0\nautoconnect=false\n[wifi]\nmode=infrastructure\ncloned-mac-address=permanent\nssid='+''.join(str(b)+';' for b in ssid.encode())+'\n[ipv4]\nmethod=auto\n[ipv6]\nmethod=auto\n'
if security in ('wpa-psk','sae'):
escaped = password.replace('\\', '\\\\').replace('\t','\\t').replace(' ', '\\s')
text += f'[wifi-security]\nkey-mgmt={security}\npsk={escaped}\n'
previous = os.umask(0o077)
try:
rows = nm('-g','SIGNAL','device','wifi','list','ifname','wlan0','--rescan','yes')
report['checks']['scanCompleted'] = rows.returncode == 0
report['visibleNetworks'] = len(rows.stdout.splitlines()) if rows.returncode == 0 else 0
report['checks']['networksFound'] = report['visibleNetworks'] > 0
target.write_text(text)
report['checks']['profileLoaded'] = nm('connection','load',str(target)).returncode == 0
activated = nm('connection','up','uuid',uid)
report['checks']['connected'] = activated.returncode == 0
if activated.returncode:
report['activationExitCode'] = activated.returncode
for family in (4, 6):
addresses = nm('--escape','no','-g',f'IP{family}.ADDRESS','device','show','wlan0')
usable = False
if addresses.returncode == 0:
for line in addresses.stdout.splitlines():
try:
address = ipaddress.ip_interface(line.strip()).ip
usable |= not (address.is_link_local or address.is_loopback or address.is_unspecified)
except ValueError:
pass
report['addressFamilies']['ipv'+str(family)] = usable
report['checks']['ipAddress'] = any(report['addressFamilies'].values())
try:
socket.setdefaulttimeout(8)
resolved = socket.getaddrinfo('example.org',443,type=socket.SOCK_STREAM)
report['checks']['dns'] = bool(resolved)
with socket.create_connection(('example.org',443),timeout=8):
report['checks']['tcp443'] = True
except OSError:
report['checks'].setdefault('dns',False)
report['checks']['tcp443'] = False
report['connectedAtUnix'] = time.time()
finally:
# The active connection continues for the test; its secret is no longer
# present in a keyfile and all NM state is destroyed at the RAM reboot.
target.unlink(missing_ok=True)
os.umask(previous)
report['passed'] = all(report['checks'].values())
print(json.dumps(report,indent=2))
return 0 if report['passed'] else 1
if __name__ == '__main__':
raise SystemExit(main())

View file

@ -0,0 +1,76 @@
# SPDX-License-Identifier: MPL-2.0
"""Read verified newc ramdisks, resolving hardlinks without extracting secrets."""
import gzip
from pathlib import PurePosixPath
import stat
import subprocess
import re
import os
def read_newc(data):
if data[:2] == b'\x1f\x8b':
data = gzip.decompress(data)
entries, payloads, offset = {}, {}, 0
while offset + 110 <= len(data):
header = data[offset:offset+110]
if header[:6] != b'070701':
raise ValueError('Expected newc ramdisk')
fields = [int(header[6+i*8:14+i*8],16) for i in range(13)]
inode, mode, uid, gid, links, mtime, size, major, minor, rmajor, rminor, namesize, check = fields
name = data[offset+110:offset+110+namesize-1].decode()
start = (offset+110+namesize+3)&~3
offset = (start+size+3)&~3
if name == 'TRAILER!!!':
break
path = PurePosixPath(name)
if path.is_absolute() or '..' in path.parts or offset > len(data):
raise ValueError('Invalid ramdisk entry')
if name == '.':
continue
key = (major,minor,inode)
payload = data[start:start+size]
entries[str(path)] = {'mode':mode,'data':payload,'key':key,'links':links}
if stat.S_ISREG(mode) and payload:
payloads[key] = payload
for entry in entries.values():
if stat.S_ISREG(entry['mode']) and entry['links'] > 1 and not entry['data']:
entry['data'] = payloads.get(entry['key'],b'')
return entries
def resolve(entries, name):
import posixpath
seen = set()
while stat.S_ISLNK(entries[name]['mode']):
if name in seen:
raise ValueError('Cyclic ramdisk link')
seen.add(name)
link = entries[name]['data'].decode()
name = posixpath.normpath(link.lstrip('/') if link.startswith('/') else posixpath.join(posixpath.dirname(name),link))
if name.startswith('../'):
raise ValueError('Link escapes ramdisk')
return entries[name]['data']
def select_helpers(entries, scratch, extra=()):
"""Copy only explicit programs and their ELF dependencies, never /etc or /root."""
selected = {}
queue = ['bin/busybox','sbin/dropbear','sbin/dmsetup','lib/aarch64-linux-gnu/libnss_files.so.2', *extra]
while queue:
name = queue.pop()
if name in selected:
continue
content = resolve(entries,name)
selected[name] = content
probe = scratch/'elf';probe.write_bytes(content)
info = subprocess.check_output(['readelf','-ld',str(probe)],text=True,env={**os.environ, 'LC_ALL':'C'})
interpreter = re.search(r'Requesting program interpreter: ([^\]]+)',info)
if interpreter:
queue.append(interpreter.group(1).lstrip('/'))
for library in re.findall(r'\(NEEDED\).*\[([^\]]+)\]',info):
found = next((base+library for base in ('lib/aarch64-linux-gnu/','usr/lib/aarch64-linux-gnu/','lib/','usr/lib/') if base+library in entries),None)
if found is None:
raise ValueError('Missing ramdisk library: '+library)
queue.append(found)
return selected

View file

@ -0,0 +1,32 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Read and validate both GPT copies on the inventoried 64 GB Sargo; no writes."""
import base64,json,struct,zlib
from pathlib import Path
def read_gpt():
result={}
with open('/dev/mmcblk0','rb',buffering=0) as disk:
for name,lba in [('primary',1),('secondary',122142719)]:
disk.seek(lba*512);h=disk.read(512)
assert h[:8]==b'EFI PART'
length,crc=struct.unpack_from('<II',h,12)
assert 92<=length<=512
check=bytearray(h[:length]);check[16:20]=b'\0'*4
assert zlib.crc32(check)==crc
current,other,first,last=struct.unpack_from('<QQQQ',h,24)
assert current==lba and {current,other}=={1,122142719}
entries,count,size,crc=struct.unpack_from('<QIII',h,72)
assert count==72 and size==128
disk.seek(entries*512);table=disk.read(count*size)
assert len(table)==count*size and zlib.crc32(table)==crc
parts={}
for i in range(count):
e=table[i*size:(i+1)*size]
label=e[56:128].decode('utf-16le').rstrip('\0')
if label:parts[label]={'index':i+1,'attributes':struct.unpack_from('<Q',e,48)[0]}
result[name]={'headerLba':lba,'entriesLba':entries,'header':base64.b64encode(h).decode(),'table':base64.b64encode(table).decode(),'partitions':parts}
assert result['primary']['table']==result['secondary']['table']
result['bootId']=Path('/proc/sys/kernel/random/boot_id').read_text().strip()
return result
print(json.dumps(read_gpt()))

45
tundra/tools/read-usb.py Normal file
View file

@ -0,0 +1,45 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Read the Tundra diagnostic protocol on an explicitly selected Linux USB device."""
import argparse
import json
import os
from pathlib import Path
import re
import sys
from tundra import ROOT, now, save
from usb_protocol import exchange
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--device',required=True,help='/dev/bus/usb/BBB/DDD, determined after manual connection')
parser.add_argument('--stdout',action='store_true',help='Emit JSON only; do not create a report file')
parser.add_argument('--check-invalid',action='store_true',help='Also verify rejection of an unknown command')
args=parser.parse_args()
if not re.fullmatch(r'/dev/bus/usb/[0-9]{3}/[0-9]{3}',args.device):parser.error('Expected an explicit usbfs device path')
selected=Path(args.device)
identity=None
for entry in Path('/sys/bus/usb/devices').iterdir():
try:
path=Path('/dev/bus/usb')/f'{int((entry/"busnum").read_text()):03d}'/f'{int((entry/"devnum").read_text()):03d}'
if path==selected:
identity={key:(entry/key).read_text().strip() for key in ('idVendor','idProduct','serial')}
except (FileNotFoundError,NotADirectoryError):continue
if identity!={'idVendor':'1d6b','idProduct':'0104','serial':'TUNDRA-DIAG'}:
raise SystemExit('Selected device is not the Tundra development diagnostic; no commands sent.')
os.umask(0o077)
results=exchange(selected,('status','version','mounts','invalid') if args.check_invalid else ('status','version','mounts'))
if args.stdout:
print(json.dumps(results));return
path=ROOT/'logs/device'/('usb-'+now().replace(':','-')+'.json')
save(path,{'recordedAt':now(),'identity':identity,'readOnly':True,'results':results})
print(json.dumps(results,indent=2));print('Saved:',path)
if __name__=='__main__':
try:
main()
except (OSError,ValueError,RuntimeError) as error:
print('Tundra USB:',error,file=sys.stderr)
sys.exit(1)

View file

@ -0,0 +1,54 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Connect to a private RAM trial using only its pinned development key."""
import argparse
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
from tundra import digest, save, now
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build',type=Path,required=True)
parser.add_argument('--stream',action='store_true',help='Keep a diagnostic command open until interrupted')
parser.add_argument('--collect',action='store_true',help='Collect boot, services and Gaia reports, excluding credentials')
parser.add_argument('command',nargs=argparse.REMAINDER,help='Command in the minimal rescue root; use chroot /proc/1/root for the native system')
args=parser.parse_args();folder=args.build.resolve();os.umask(0o077)
build=json.loads((folder/'build.json').read_text())
if digest(folder/build['image'])!=build['imageSha256']:
raise ValueError('Trial image changed')
if not (folder/'boot-report.json').is_file():
raise ValueError('This image has not been sent with the checked boot tool')
ssh=['ssh','-F','/dev/null','-o','BatchMode=yes','-o','IdentitiesOnly=yes',
'-o','ConnectTimeout=5','-o','ServerAliveInterval=5','-o','ServerAliveCountMax=6',
'-o','StrictHostKeyChecking=yes','-o','HostKeyAlgorithms=+ssh-rsa',
'-o','UserKnownHostsFile='+str(folder/'known_hosts'),'-i',str(folder/'client-key'),
'-p','2222','root@10.15.19.82']
if args.collect:
commands={
'pid1':'cat /proc/1/cmdline',
'mounts':'cat /proc/1/mounts',
'bootLog':'cat /proc/1/root/run/boot.log',
'identity':'cat /proc/1/root/etc/os-release',
'services':'chroot /proc/1/root /usr/bin/systemctl --failed --no-pager',
'journal':'chroot /proc/1/root /usr/bin/journalctl -b -u tundra-hal -u tundra-display --no-pager -n 200',
'lxc':'cat /proc/1/root/run/tundra/lxc.log',
'gaia':'cat /proc/1/root/opt/vulpes/logs/test.json',
'gecko':'tail -100 /proc/1/root/opt/vulpes/logs/gecko.log',
}
report={'recordedAt':now(),'imageSha256':build['imageSha256'],'readOnlyCollection':True,'checks':{}}
for name,command in commands.items():
result=subprocess.run(ssh+[command],capture_output=True,text=True,timeout=30)
report['checks'][name]={'returncode':result.returncode,'stdout':result.stdout,'stderr':result.stderr}
output=folder/('collection-'+now().replace(':','-')+'.json');save(output,report);print(output)
return
if not args.command:parser.error('Use --collect or provide a command')
result=subprocess.run(ssh+[shlex.join(args.command)],timeout=None if args.stream else 60)
sys.exit(result.returncode)
if __name__=='__main__':main()

View file

@ -0,0 +1,100 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Resolve the upstream Gonk reference manifest; no source checkout or build."""
import argparse
import concurrent.futures
import copy
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
from tundra import ROOT, checked_source, flatten_manifest, locked, now, save
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target", choices=("sargo", "emulator-10"), default="sargo")
parser.add_argument("--jobs", type=int, default=4)
args = parser.parse_args()
if not 1 <= args.jobs <= 8:
parser.error("Use 1 to 8 network workers")
folder = checked_source("capyloon-manifests")
projects = flatten_manifest(folder, args.target + ".xml")
destination = ROOT / "out" / args.target
report_file = destination / "resolved-reference.json"
manifest_file = destination / "reference-pinned.xml"
# Never leave an earlier, apparently valid manifest after a failed resolution.
manifest_file.unlink(missing_ok=True)
prior = json.loads(report_file.read_text()) if report_file.exists() else {}
cache = {(x["url"], x["revision"]): x for x in prior.get("projects", []) if x.get("commit")}
def resolve(project):
item = dict(project)
key = (item["url"], item["revision"])
if key in cache:
return dict(item, commit=cache[key]["commit"])
ref = item["revision"]
if re.fullmatch(r"[0-9a-f]{40}", ref or ""):
# Already pinned upstream; the object will be checked when fetched.
return dict(item, commit=ref, verification="upstream-pin; object not fetched")
patterns = [ref, ref + "^{}"] if ref.startswith("refs/") else ["refs/heads/" + ref, "refs/tags/" + ref, "refs/tags/" + ref + "^{}"]
try:
p = subprocess.run(["git", "ls-remote", item["url"], *patterns],
env=dict(os.environ, GIT_TERMINAL_PROMPT="0"),
capture_output=True, text=True, timeout=45, check=True)
refs = [line.split() for line in p.stdout.splitlines()]
if not refs:
raise ValueError("Upstream revision does not exist")
commit = next((sha for sha, name in refs if name.endswith("^{}")), refs[0][0])
if not re.fullmatch(r"[0-9a-f]{40}", commit):
raise ValueError("Invalid upstream object ID")
return dict(item, commit=commit)
except (ValueError, subprocess.SubprocessError) as error:
detail = getattr(error, "stderr", None) or str(error)
return dict(item, error=detail[-1500:])
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as pool:
futures = [pool.submit(resolve, project) for project in projects]
for future in concurrent.futures.as_completed(futures):
results.append(future.result())
save(report_file, {"recordedAt": now(), "complete": False,
"manifestCommit": locked("capyloon-manifests")["commit"],
"projects": results})
if len(results) % 25 == 0:
print(f"Resolved {len(results)}/{len(projects)} references", flush=True)
results.sort(key=lambda x: x["path"])
errors = [x for x in results if "error" in x]
save(report_file, {"recordedAt": now(), "complete": not errors,
"manifestCommit": locked("capyloon-manifests")["commit"],
"target": args.target, "referenceOnly": True, "projects": results})
if errors:
for item in errors:
print(f"Unavailable: {item['path']}: {item['error']}")
return 1
root = ET.Element("manifest")
revisions = {x["path"]: x["commit"] for x in results}
def expand(name):
for element in ET.parse(folder / name).getroot():
if element.tag == "include":
expand(element.attrib["name"])
else:
element = copy.deepcopy(element)
if element.tag == "project":
element.set("revision", revisions[element.get("path", element.attrib["name"])])
root.append(element)
expand(args.target + ".xml")
ET.indent(root)
ET.ElementTree(root).write(manifest_file, encoding="utf-8", xml_declaration=True)
print(f"Pinned reference manifest: {manifest_file}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,74 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Verify or boot the locally qualified Sargo development image in RAM."""
import argparse
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
from tundra import ROOT, digest
def qualified_build():
pointer=json.loads((ROOT/'out/sargo-native-qualified.json').read_text())
folder=(ROOT/pointer['directory']).resolve()
folder.relative_to((ROOT/'out').resolve())
report=folder/'qualification.json'
if digest(report)!=pointer['qualificationSha256']:
raise ValueError('Qualification report changed')
qualification=json.loads(report.read_text())
if not qualification['passed']:
raise ValueError('Expected a qualified native boot')
build=json.loads((folder/'build.json').read_text())
if build.get('interactiveDiagnostics'):
raise ValueError('Interactive diagnostic images must be launched explicitly with test-rescue-boot.py')
if qualification['imageSha256']!=build['imageSha256']:
raise ValueError('Image differs from the qualified build')
for name,expected in qualification['evidenceSha256'].items():
evidence=(folder/name).resolve();evidence.relative_to(folder)
if digest(evidence)!=expected:
raise ValueError('Qualification evidence changed: '+name)
subprocess.run([sys.executable,str(ROOT/'tools/test-rescue-boot.py'),'--build',str(folder)],check=True)
return folder
def main():
parser=argparse.ArgumentParser(description=__doc__)
mode=parser.add_mutually_exclusive_group()
mode.add_argument('--boot',action='store_true',help='Boot the connected inventoried Sargo, already in Fastboot')
mode.add_argument('--collect',action='store_true',help='Collect the currently running RAM trial')
parser.add_argument('--reboot-from-droidian',action='store_true',help='Use the existing authenticated SSH master to request Fastboot first')
args=parser.parse_args()
if args.reboot_from_droidian and not args.boot:
parser.error('--reboot-from-droidian requires --boot')
folder=qualified_build()
print('Vulpes / Tundra Sargo — Gaia 2.7, Gecko 156.0.1',flush=True)
build=json.loads((folder/'build.json').read_text())
if build.get('persistentState'):
installed = folder/'installation-validation.json'
if installed.is_file() and json.loads(installed.read_text()).get('passed'):
print('Cette image est installée dans boot_a : un démarrage normal lance Vulpes.',flush=True)
print('Cet outil vérifie le candidat ou lance un essai RAM avec --boot ; il ne flashe pas.',flush=True)
else:
print('Essai du noyau par Fastboot ; données Tundra persistantes. Aucun flash.',flush=True)
if build['watchdogSeconds'] is None and not installed.is_file():
print('Pas de redémarrage automatique. Sans installation, un reboot revient au système conservé.',flush=True)
else:
print('Essai en RAM de 10 minutes, puis retour à Droidian. Aucun flash.',flush=True)
if args.reboot_from_droidian:
spec=importlib.util.spec_from_file_location('boot',ROOT/'tools/test-sargo-boot.py')
boot=importlib.util.module_from_spec(spec);spec.loader.exec_module(boot)
plan,_=boot.checked_plan();boot.reboot_from_droidian(plan)
if args.boot:
subprocess.run([sys.executable,str(ROOT/'tools/test-rescue-boot.py'),'--build',str(folder),'--boot'],check=True)
elif args.collect:
subprocess.run([sys.executable,str(ROOT/'tools/rescue-ssh.py'),'--build',str(folder),'--collect'],check=True)
else:
print('Vérification locale terminée. Utiliser --boot pour démarrer depuis Fastboot.')
if __name__=='__main__':
try:main()
except (OSError,ValueError,RuntimeError,subprocess.CalledProcessError) as error:
sys.exit(str(error))

103
tundra/tools/run-vm.py Normal file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Boot Tundra's graphical guest; no NIC, no host shares, disposable disk overlay."""
import argparse
import fcntl
import json
import hashlib
import os
from pathlib import Path
import shutil
import socket
import subprocess
import tempfile
import time
ROOT=Path(__file__).resolve().parents[1]
OUT=ROOT/'out/graphical-vm'
LOG=ROOT/'logs'
def qmp(path, command, arguments=None):
with socket.socket(socket.AF_UNIX) as sock:
sock.settimeout(5)
sock.connect(str(path))
stream=sock.makefile('rwb',buffering=0)
json.loads(stream.readline())
stream.write(b'{"execute":"qmp_capabilities"}\n')
while 'return' not in json.loads(stream.readline()): pass
stream.write((json.dumps({'execute':command,'arguments':arguments or {}})+'\n').encode())
while True:
result=json.loads(stream.readline())
if 'error' in result: raise RuntimeError(result)
if 'return' in result: return result['return']
def main():
parser=argparse.ArgumentParser()
parser.add_argument('--test',action='store_true',help='Headless real DRM display, guest tests and screenshot')
parser.add_argument('--usb-test',action='store_true',help='Also test read-only FunctionFS on dummy_hcd inside the VM')
parser.add_argument('--show',action='store_true',help='Show the QEMU window while running the automated test')
parser.add_argument('--memory',type=int,default=3072)
args=parser.parse_args()
for name in ('root.ext4','vmlinuz','initramfs.cpio.gz','build.json'):
if not (OUT/name).is_file(): raise SystemExit('Build first: python3 tundra/tools/build-vm.py')
LOG.mkdir(exist_ok=True)
label='graphical-vm' if args.test else 'graphical-vm-session'
with (LOG/'graphical-vm.lock').open('w') as guard, tempfile.TemporaryDirectory(prefix='tundra-qmp-') as temp:
fcntl.flock(guard,fcntl.LOCK_EX|fcntl.LOCK_NB)
qmp_path=Path(temp)/'qmp.sock'
serial=LOG/(label+'.log')
serial.write_text('')
command=['qemu-system-x86_64','-enable-kvm','-cpu','host','-smp','2','-m',str(args.memory),
'-kernel',str(OUT/'vmlinuz'),'-initrd',str(OUT/'initramfs.cpio.gz'),
'-append','console=ttyS0 rdinit=/init panic=-1'+(' tundra.test=1' if args.test else '')+(' tundra.usb=1' if args.usb_test else ''),
'-drive',f'file={OUT}/root.ext4,format=raw,if=virtio,snapshot=on',
'-net','none','-device','virtio-vga,xres=600,yres=960','-vga','none',
'-device','virtio-keyboard-pci','-device','virtio-tablet-pci',
'-display','none' if args.test and not args.show else 'gtk,zoom-to-fit=off',
'-serial',f'file:{serial}','-qmp',f'unix:{qmp_path},server=on,wait=off','-no-reboot']
captured=False
clicked=False
print(f'Tundra VM — {args.memory} MiB RAM, disque de session jetable, réseau local uniquement.',flush=True)
proc=subprocess.Popen(command)
deadline=time.monotonic()+240 if args.test else float('inf')
try:
while proc.poll() is None:
log=serial.read_text(errors='replace')
if 'TUNDRA_POINTER_HOME' in log and not clicked:
qmp(qmp_path,'screendump',{'filename':str(LOG/(label+'-clock.ppm'))})
qmp(qmp_path,'input-send-event',{'events':[
{'type':'abs','data':{'axis':'x','value':16384}},
{'type':'abs','data':{'axis':'y','value':32085}}]})
time.sleep(.5)
qmp(qmp_path,'input-send-event',{'events':[{'type':'btn','data':{'button':'left','down':True}}]})
time.sleep(.1)
qmp(qmp_path,'input-send-event',{'events':[{'type':'btn','data':{'button':'left','down':False}}]})
clicked=True
if 'TUNDRA_GUI_READY' in log and not captured:
qmp(qmp_path,'screendump',{'filename':str(LOG/(label+'.ppm'))})
captured=True
print('Gaia guest checks passed; screenshot captured.',flush=True)
if time.monotonic()>deadline:
try: qmp(qmp_path,'screendump',{'filename':str(LOG/(label+'-failed.ppm'))})
except Exception: pass
raise TimeoutError(f'Guest did not finish; see {serial}')
time.sleep(1)
finally:
if proc.poll() is None:
proc.terminate()
try: proc.wait(timeout=10)
except subprocess.TimeoutExpired: proc.kill(); proc.wait()
text=serial.read_text(errors='replace')
reports=[line.removeprefix('TUNDRA_GUI_TEST ') for line in text.splitlines() if line.startswith('TUNDRA_GUI_TEST ')]
decoded=[json.loads(item) for item in reports]
report=next((item for item in decoded if 'uid' in item), decoded[-1] if decoded else {'passed':False,'error':'No guest test result'})
usb_reports=[json.loads(line.removeprefix('TUNDRA_USB_TEST ')) for line in text.splitlines() if line.startswith('TUNDRA_USB_TEST ')]
if args.usb_test:
report['usb']=usb_reports[-1] if usb_reports else {'passed':False,'error':'No USB result'}
report['passed']=report['passed'] and report['usb']['passed']
report['buildManifestSha256']=hashlib.sha256((OUT/'build.json').read_bytes()).hexdigest()
report.update(screenshot=captured, qemuExit=proc.returncode, scope='x86_64 VM only; no Pixel/HAL test')
(LOG/(label+'-test.json')).write_text(json.dumps(report,indent=2)+'\n')
print(json.dumps(report,indent=2))
if args.test and not (report['passed'] and captured and proc.returncode==0): raise SystemExit(1)
if __name__=='__main__': main()

View file

@ -0,0 +1,108 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Stage a private runtime reference without importing the phone's configuration."""
import argparse
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import posixpath
import tarfile
def destination(name):
path = PurePosixPath(name)
if path.is_absolute() or '..' in path.parts or not path.parts or path.parts[0] not in ('usr', 'bin', 'sbin', 'lib', 'lib64'):
raise ValueError('Invalid archive path: '+name)
return str(path)
def link_target(name, target):
absolute = posixpath.normpath(target if target.startswith('/') else
posixpath.join('/', posixpath.dirname(name), target))
return posixpath.relpath(absolute, '/'+posixpath.dirname(name))
def merged_path(name):
return 'usr/'+name if name.split('/')[0] in ('bin', 'sbin', 'lib', 'lib64') else name
def stage(source, root):
manifest = json.loads((source/'manifest.json').read_text())
with (source/'runtime.tar').open('rb') as stream:
digest = hashlib.file_digest(stream, 'sha256').hexdigest()
if digest != manifest['archiveSha256']:
raise ValueError('Runtime archive checksum mismatch')
root.mkdir(parents=True, exist_ok=False)
links = []
with tarfile.open(source/'runtime.tar') as archive:
for item in archive:
name = destination(item.name)
record = manifest['files']['/'+name]
output_name = merged_path(name)
target = root/output_name
target.parent.mkdir(parents=True, exist_ok=True)
if item.issym():
if record['symlink'] != item.linkname:
raise ValueError('Symlink metadata mismatch: '+name)
links.append((output_name, item.linkname))
elif item.isfile():
with archive.extractfile(item) as stream, target.open('xb') as output:
hasher = hashlib.sha256()
while data := stream.read(1024*1024):
hasher.update(data)
output.write(data)
if hasher.hexdigest() != record['sha256']:
raise ValueError('File checksum mismatch: '+name)
target.chmod(item.mode & 0o1777)
else:
raise ValueError('Unsupported archive member: '+name)
# Extract regular files first so an archive symlink can never redirect a write.
for name, target in links:
path = root/name
if path.exists() or path.is_symlink() or any(p.is_symlink() for p in path.parents):
raise ValueError('Archive symlink conflicts with another path: '+name)
path.symlink_to(link_target(name, target))
for name in ('usr/bin', 'usr/sbin', 'usr/lib', 'etc/fonts', 'dev', 'proc', 'sys', 'run', 'tmp', 'var/tmp', 'var/log',
'opt/vulpes', 'home/vulpes', 'root'):
(root/name).mkdir(parents=True, exist_ok=True)
for name in ('bin', 'sbin', 'lib'):
if not (root/name).exists():
(root/name).symlink_to('usr/'+name)
if not (root/'usr/bin/sh').exists() and not (root/'usr/bin/sh').is_symlink():
(root/'usr/bin/sh').symlink_to('bash')
config = {
'passwd': 'root:x:0:0:root:/root:/bin/bash\nvulpes:x:1000:1000:Vulpes:/home/vulpes:/bin/bash\n',
'group': 'root:x:0:\nvulpes:x:1000:\n',
'shadow': 'root:!:20000:0:99999:7:::\nvulpes:!:20000:0:99999:7:::\n',
'nsswitch.conf': 'passwd: files\ngroup: files\nshadow: files\nhosts: files dns\n',
'hosts': '127.0.0.1 localhost\n::1 localhost\n',
'ld.so.conf': '/usr/lib/aarch64-linux-gnu\n/usr/lib\n',
'fonts/fonts.conf': '<?xml version="1.0"?><!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd"><fontconfig><dir>/usr/share/fonts</dir><cachedir prefix="xdg">fontconfig</cachedir><alias><family>sans-serif</family><prefer><family>Nimbus Sans</family></prefer></alias></fontconfig>\n',
'os-release': 'NAME="Tundra runtime reference"\nID=tundra\nPRETTY_NAME="Tundra ARM64 reference (not bootable)"\n',
}
for name, value in config.items():
(root/'etc'/name).write_text(value)
(root/'etc/shadow').chmod(0o600)
for name in ('tmp', 'var/tmp'):
(root/name).chmod(0o1777)
report = {'scope': 'Package runtime reference only; no init, HAL mounts or standalone boot',
'archiveSha256': digest, 'packages': len(manifest['packages']),
'packageFiles': len(manifest['files']), 'sourceMissingFiles': manifest['missing'],
'hostConfigurationCopied': False, 'setuidPrograms': False}
(root.parent/(root.name+'-stage.json')).write_text(json.dumps(report, indent=2)+'\n')
return report
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source', type=Path, required=True)
parser.add_argument('--root', type=Path, required=True)
args = parser.parse_args()
if os.geteuid() == 0:
parser.error('Stage as an unprivileged user')
print(json.dumps(stage(args.source, args.root), indent=2))
if __name__ == '__main__':
main()

View file

@ -0,0 +1,43 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Play a quiet test tone and measure microphone PCM; retain no recording."""
import json
import math
import struct
import subprocess
import time
def main():
report = {'recordedAtUnix': time.time(), 'recordingRetained': False,
'scope': 'Physical PulseAudio endpoints, not in-call audio'}
# Use a per-stream volume; leave the user's sink volume and mute unchanged.
tone = b''.join(struct.pack('<h', int(16000*math.sin(2*math.pi*440*i/48000)))
for i in range(48000))
played = subprocess.run(['paplay', '--raw', '--rate=48000', '--channels=1',
'--format=s16le', '--volume=6554',
'--device=sink.primary_output'], input=tone,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=10)
report['playback'] = {'exitCode': played.returncode, 'device': 'sink.primary_output',
'audibilityConfirmed': False}
recording = subprocess.Popen(['parec', '--raw', '--rate=48000', '--channels=1',
'--format=s16le', '--device=source.droid'],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
try: data, error = recording.communicate(timeout=3)
except subprocess.TimeoutExpired:
recording.terminate()
data, error = recording.communicate(timeout=3)
finally:
if recording.poll() is None:
recording.kill()
recording.wait()
samples = struct.unpack('<'+'h'*(len(data)//2), data[:len(data)//2*2])
report['microphone'] = {'device': 'source.droid', 'sampleCount': len(samples),
'rms': round(math.sqrt(sum(x*x for x in samples)/len(samples)), 2) if samples else None,
'peak': max(map(abs, samples), default=None),
'nonSilent': any(samples)}
print(json.dumps(report, indent=2))
if __name__ == '__main__': main()

View file

@ -0,0 +1,63 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Smoke-test Droidian's Qt AAL camera backend without saving an image.
The old gst-droid probe is not a valid Sargo test: it loads the obsolete
Stagefright codec library before it reaches the Android camera provider. The
shipping camera application uses QtMultimedia's AAL plugin instead. This
check reports configuration and process startup only. A process can remain
alive while the camera backend is blocked; that does not validate the sensor.
"""
import configparser
import json
import os
from pathlib import Path
import subprocess
import time
def main():
config_path = Path('/usr/lib/droidian/device/droidian-camera.conf')
config = configparser.ConfigParser()
config.read(config_path)
backend = config.get('General', 'backend', fallback='').strip().lower()
env = os.environ.copy()
env.setdefault('QT_QPA_PLATFORM', 'offscreen')
env.setdefault('XDG_RUNTIME_DIR', '/run/user/32011')
env.setdefault('HOME', '/home/droidian')
started = time.monotonic()
try:
result = subprocess.run(
['/usr/bin/droidian-camera'], env=env, capture_output=True,
text=True, timeout=8)
exit_code = result.returncode
except subprocess.TimeoutExpired as error:
# Timeout is deliberately not treated as successful initialization.
result = error
exit_code = 124
stderr = getattr(result, 'stderr', '') or ''
stdout = getattr(result, 'stdout', '') or ''
if isinstance(stderr, bytes):
stderr = stderr.decode(errors='replace')
if isinstance(stdout, bytes):
stdout = stdout.decode(errors='replace')
print(json.dumps({
'recordedAtUnix': time.time(),
'backendConfig': str(config_path),
'backend': backend,
'applicationInstalled': Path('/usr/bin/droidian-camera').exists(),
'processStayedAlive': exit_code == 124,
'initializationVerified': False,
'previewVerified': False,
'captureTested': False,
'recordingRetained': False,
'stagefrightWarning': 'libstagefright_ccodec.so' in stderr,
'exitCode': exit_code,
'seconds': round(time.monotonic() - started, 2),
'stderrTail': stderr[-1000:],
'stdoutTail': stdout[-1000:],
}, indent=2))
if __name__ == '__main__':
main()

View file

@ -0,0 +1,62 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Count HAL preview frames through gst-droid; never save pictures."""
import ctypes as C
import ctypes.util
import json
import time
def main():
gst = C.CDLL(ctypes.util.find_library('gstreamer-1.0'))
obj = C.CDLL(ctypes.util.find_library('gobject-2.0'))
glib = C.CDLL(ctypes.util.find_library('glib-2.0'))
def api(lib, name, returns, args):
fun = getattr(lib, name); fun.restype = returns; fun.argtypes = args; return fun
ptr = C.c_void_p
api(gst, 'gst_init', None, [ptr,ptr])(None,None)
parse = api(gst,'gst_parse_launch',ptr,[C.c_char_p,ptr])
get = api(gst,'gst_bin_get_by_name',ptr,[ptr,C.c_char_p])
state = api(gst,'gst_element_set_state',C.c_int,[ptr,C.c_int])
unref = api(gst,'gst_object_unref',None,[ptr])
bus_get = api(gst,'gst_element_get_bus',ptr,[ptr])
pop = api(gst,'gst_bus_timed_pop_filtered',ptr,[ptr,C.c_uint64,C.c_uint])
frames = []
callback_type = C.CFUNCTYPE(None,ptr,ptr,ptr,ptr)
@callback_type
def handoff(sink, buffer, pad, data):
frames.append(time.monotonic())
connect = api(obj,'g_signal_connect_data',C.c_ulong,[ptr,C.c_char_p,callback_type,ptr,ptr,C.c_int])
pipeline = parse(b'droidcamsrc name=camera ! fakesink name=sink signal-handoffs=true sync=false',None)
if not pipeline: raise RuntimeError('Camera pipeline unavailable')
sink = get(pipeline,b'sink'); bus = bus_get(pipeline)
if not sink: raise RuntimeError('Camera sink unavailable')
connect(sink,b'handoff',handoff,None,None,0)
report = {'recordedAtUnix':time.time(),'recordingRetained':False,'backend':'gst-droid','error':None}
started = time.monotonic()
try:
report['stateChangeResult'] = state(pipeline,4)
deadline = started+12
while time.monotonic()<deadline and len(frames)<20:
message = pop(bus,100000000,2) # GST_MESSAGE_ERROR
if message:
error = ptr(); debug = C.c_char_p()
api(gst,'gst_message_parse_error',None,[ptr,ptr,ptr])(message,C.byref(error),C.byref(debug))
class GError(C.Structure): _fields_ = [('domain',C.c_uint),('code',C.c_int),('message',C.c_char_p)]
if error:
e=C.cast(error,C.POINTER(GError)).contents
report['error']={'code':e.code,'message':e.message.decode(errors='replace')}
api(glib,'g_error_free',None,[ptr])(error)
api(glib,'g_free',None,[ptr])(C.cast(debug,ptr))
api(gst,'gst_message_unref',None,[ptr])(message)
break
report['previewFrames'] = len(frames)
report['seconds'] = round(time.monotonic()-started,2)
report['passed'] = len(frames)>=20 and not report['error']
finally:
state(pipeline,1)
unref(sink); unref(bus); unref(pipeline)
print(json.dumps(report,indent=2),flush=True)
if __name__ == '__main__': main()

View file

@ -0,0 +1,42 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Temporarily boot the private rescue image on the inventoried Sargo. Never flash."""
import argparse
import importlib.util
import json
from pathlib import Path
import subprocess
import time
from tundra import ROOT,digest,save,now
def verify_sources(folder, build):
base=folder/'sources' if build.get('sourceSnapshot') else ROOT.parent
for name,sha in {**{'tundra/'+n:h for n,h in build['sources'].items()},
**build.get('sharedUiHashes',{})}.items():
path=(base/name).resolve()
path.relative_to(base.resolve())
if digest(path)!=sha:raise ValueError('Build source changed: '+name)
def main():
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--build',type=Path,required=True);p.add_argument('--boot',action='store_true');args=p.parse_args()
spec=importlib.util.spec_from_file_location('boot',ROOT/'tools/test-sargo-boot.py');boot=importlib.util.module_from_spec(spec);spec.loader.exec_module(boot)
plan,_=boot.checked_plan();folder=args.build.resolve();build=json.loads((folder/'build.json').read_text());image=folder/build['image']
if build['imageSha256']!=digest(image) or build['serialSha256']!=plan['serialSha256'] or not build['roundtripPassed']:
raise ValueError('Image or target provenance mismatch')
verify_sources(folder,build)
print('Private image, source and backup provenance checked.',flush=True)
if not args.boot:return
# The inventoried HAL can take about three minutes to shut down after SSH
# closes. Allow that transition; identity checks still precede any boot.
serial=boot.find_fastboot(plan['serialSha256'],300);values=boot.check_device(serial,plan['serialSha256'])
report={'recordedAt':now(),'imageSha256':digest(image),'bootloader':values,'operation':'fastboot boot','flashCommandsSent':False}
result=boot.fastboot(serial,'boot',str(image));report['output']=result.stdout+result.stderr
save(folder/'boot-report.json',report)
deadline=build['watchdogSeconds']
message=f'Automatic return after {deadline} seconds.' if deadline else 'Persistent development boot; no automatic reboot.'
print(f'RAM boot sent. USB SSH: 10.15.19.82:2222. {message}',flush=True)
if __name__=='__main__':main()

Some files were not shown because too many files have changed in this diff Show more