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:
parent
93e3a48004
commit
b38dea0597
25901 changed files with 9982925 additions and 369 deletions
6
tundra/.gitignore
vendored
Normal file
6
tundra/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/sources/
|
||||
/downloads/
|
||||
/out/
|
||||
/logs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
373
tundra/LICENSE
Normal file
373
tundra/LICENSE
Normal 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
108
tundra/boot/init.c
Normal 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
82
tundra/boot/native-root.sh
Executable 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
55
tundra/boot/rescue-init.sh
Executable 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
132
tundra/boot/usb-init.c
Normal 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
14
tundra/boot/watchdog.c
Normal 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);}
|
||||
}
|
||||
50
tundra/devices/sargo/device.json
Normal file
50
tundra/devices/sargo/device.json
Normal 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
|
||||
}
|
||||
}
|
||||
80
tundra/droidian/backup-boot.py
Normal file
80
tundra/droidian/backup-boot.py
Normal 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()
|
||||
102
tundra/droidian/check-runtime.py
Normal file
102
tundra/droidian/check-runtime.py
Normal 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()
|
||||
151
tundra/droidian/compositor-trial.py
Normal file
151
tundra/droidian/compositor-trial.py
Normal 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()
|
||||
155
tundra/droidian/export-runtime.py
Normal file
155
tundra/droidian/export-runtime.py
Normal 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()
|
||||
39
tundra/droidian/graphics/egl-config.c
Normal file
39
tundra/droidian/graphics/egl-config.c
Normal 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;
|
||||
}
|
||||
11
tundra/droidian/graphics/libhybris-b22d041.patch
Normal file
11
tundra/droidian/graphics/libhybris-b22d041.patch
Normal 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;
|
||||
6
tundra/droidian/graphics/vendor.json
Normal file
6
tundra/droidian/graphics/vendor.json
Normal 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"
|
||||
}
|
||||
18
tundra/droidian/initialize-runtime.sh
Normal file
18
tundra/droidian/initialize-runtime.sh
Normal 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
|
||||
29
tundra/droidian/inventory-hardware.py
Normal file
29
tundra/droidian/inventory-hardware.py
Normal 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()
|
||||
58
tundra/droidian/platform-inventory.py
Normal file
58
tundra/droidian/platform-inventory.py
Normal 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()
|
||||
43
tundra/droidian/prepare-session-root.py
Normal file
43
tundra/droidian/prepare-session-root.py
Normal 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()
|
||||
614
tundra/droidian/probe.sys.mjs
Normal file
614
tundra/droidian/probe.sys.mjs
Normal 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
208
tundra/droidian/session.py
Normal 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
|
||||
8
tundra/manifests/capyloon-sargo.reference.json
Normal file
8
tundra/manifests/capyloon-sargo.reference.json
Normal 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"
|
||||
}
|
||||
416
tundra/manifests/capyloon-sargo.reference.xml
Normal file
416
tundra/manifests/capyloon-sargo.reference.xml
Normal 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>
|
||||
9
tundra/manifests/engine-reference.json
Normal file
9
tundra/manifests/engine-reference.json
Normal 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"
|
||||
}
|
||||
69
tundra/manifests/sources.lock.json
Normal file
69
tundra/manifests/sources.lock.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
tundra/manifests/weston.lock.json
Normal file
6
tundra/manifests/weston.lock.json
Normal 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
156
tundra/runtime/buttons.py
Normal 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')
|
||||
216
tundra/runtime/call-audio.py
Normal file
216
tundra/runtime/call-audio.py
Normal 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()
|
||||
269
tundra/runtime/camera-preview.py
Normal file
269
tundra/runtime/camera-preview.py
Normal 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())
|
||||
98
tundra/runtime/hal_policy.py
Normal file
98
tundra/runtime/hal_policy.py
Normal 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}
|
||||
64
tundra/runtime/hardware-status.py
Normal file
64
tundra/runtime/hardware-status.py
Normal 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()
|
||||
39
tundra/runtime/lxc-guard.py
Normal file
39
tundra/runtime/lxc-guard.py
Normal 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()
|
||||
101
tundra/runtime/prepare-hardware.py
Normal file
101
tundra/runtime/prepare-hardware.py
Normal 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')
|
||||
240
tundra/runtime/prepare-native.py
Normal file
240
tundra/runtime/prepare-native.py
Normal 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())
|
||||
40
tundra/runtime/prepare-network.py
Normal file
40
tundra/runtime/prepare-network.py
Normal 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()
|
||||
186
tundra/runtime/radio-control.py
Normal file
186
tundra/runtime/radio-control.py
Normal 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')
|
||||
86
tundra/runtime/setup-hal.py
Normal file
86
tundra/runtime/setup-hal.py
Normal 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()
|
||||
79
tundra/runtime/sms-inbox.py
Normal file
79
tundra/runtime/sms-inbox.py
Normal 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()
|
||||
275
tundra/runtime/wifi-control.py
Normal file
275
tundra/runtime/wifi-control.py
Normal 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
62
tundra/tests/fixtures/pulse14/cards.txt
vendored
Normal 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
87
tundra/tests/fixtures/pulse14/sinks.txt
vendored
Normal 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
|
||||
81
tundra/tests/fixtures/pulse14/sources.txt
vendored
Normal file
81
tundra/tests/fixtures/pulse14/sources.txt
vendored
Normal 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
|
||||
46
tundra/tests/test_boot_backup.py
Normal file
46
tundra/tests/test_boot_backup.py
Normal 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()
|
||||
36
tundra/tests/test_build_sources.py
Normal file
36
tundra/tests/test_build_sources.py
Normal 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()
|
||||
34
tundra/tests/test_buttons.py
Normal file
34
tundra/tests/test_buttons.py
Normal 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)
|
||||
62
tundra/tests/test_call_audio.py
Normal file
62
tundra/tests/test_call_audio.py
Normal 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()
|
||||
38
tundra/tests/test_fonts.py
Normal file
38
tundra/tests/test_fonts.py
Normal 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')
|
||||
58
tundra/tests/test_hardware_status.py
Normal file
58
tundra/tests/test_hardware_status.py
Normal 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()
|
||||
29
tundra/tests/test_helper_dependencies.py
Normal file
29
tundra/tests/test_helper_dependencies.py
Normal 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()
|
||||
65
tundra/tests/test_native_launch.py
Normal file
65
tundra/tests/test_native_launch.py
Normal 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()
|
||||
81
tundra/tests/test_persistent_install.py
Normal file
81
tundra/tests/test_persistent_install.py
Normal 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()
|
||||
52
tundra/tests/test_persistent_state.py
Normal file
52
tundra/tests/test_persistent_state.py
Normal 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()
|
||||
90
tundra/tests/test_radio_control.py
Normal file
90
tundra/tests/test_radio_control.py
Normal 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()
|
||||
131
tundra/tests/test_ramdisk.py
Normal file
131
tundra/tests/test_ramdisk.py
Normal 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()
|
||||
99
tundra/tests/test_runtime_reference.py
Normal file
99
tundra/tests/test_runtime_reference.py
Normal 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)
|
||||
30
tundra/tests/test_slot_finalization.py
Normal file
30
tundra/tests/test_slot_finalization.py
Normal 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)
|
||||
43
tundra/tests/test_sms_store.py
Normal file
43
tundra/tests/test_sms_store.py
Normal 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()
|
||||
40
tundra/tests/test_ssh_inventory.py
Normal file
40
tundra/tests/test_ssh_inventory.py
Normal 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')
|
||||
45
tundra/tests/test_system_image.py
Normal file
45
tundra/tests/test_system_image.py
Normal 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()
|
||||
113
tundra/tests/test_temporary_boot.py
Normal file
113
tundra/tests/test_temporary_boot.py
Normal 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
134
tundra/tests/test_tools.py
Normal 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
36
tundra/tests/test_usb.py
Normal 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())
|
||||
64
tundra/tests/test_usb_transport.py
Normal file
64
tundra/tests/test_usb_transport.py
Normal 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()
|
||||
120
tundra/tests/test_wifi_control.py
Normal file
120
tundra/tests/test_wifi_control.py
Normal 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()
|
||||
54
tundra/tools/audit-kernel.py
Normal file
54
tundra/tools/audit-kernel.py
Normal 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()
|
||||
62
tundra/tools/backup-sargo.py
Normal file
62
tundra/tools/backup-sargo.py
Normal 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()
|
||||
26
tundra/tools/boot_backup.py
Normal file
26
tundra/tools/boot_backup.py
Normal 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
|
||||
31
tundra/tools/build-droidian-graphics.py
Normal file
31
tundra/tools/build-droidian-graphics.py
Normal 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()
|
||||
111
tundra/tools/build-installed-sargo.py
Normal file
111
tundra/tools/build-installed-sargo.py
Normal 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()
|
||||
239
tundra/tools/build-rescue-boot.py
Normal file
239
tundra/tools/build-rescue-boot.py
Normal 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()
|
||||
85
tundra/tools/build-runtime-image.py
Normal file
85
tundra/tools/build-runtime-image.py
Normal 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()
|
||||
56
tundra/tools/build-state-image.py
Normal file
56
tundra/tools/build-state-image.py
Normal 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()
|
||||
129
tundra/tools/build-system-image.py
Normal file
129
tundra/tools/build-system-image.py
Normal 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
44
tundra/tools/build-usb.py
Normal 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
254
tundra/tools/build-vm.py
Normal 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()
|
||||
100
tundra/tools/collect-device-ssh.py
Normal file
100
tundra/tools/collect-device-ssh.py
Normal 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())
|
||||
91
tundra/tools/collect-device.py
Normal file
91
tundra/tools/collect-device.py
Normal 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())
|
||||
68
tundra/tools/deploy-droidian.py
Normal file
68
tundra/tools/deploy-droidian.py
Normal 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()
|
||||
129
tundra/tools/droidian-trial.py
Normal file
129
tundra/tools/droidian-trial.py
Normal 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()
|
||||
128
tundra/tools/finalize-sargo-slot.py
Normal file
128
tundra/tools/finalize-sargo-slot.py
Normal 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()
|
||||
93
tundra/tools/inspect-hardware.py
Normal file
93
tundra/tools/inspect-hardware.py
Normal 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))
|
||||
70
tundra/tools/inspect-native.py
Normal file
70
tundra/tools/inspect-native.py
Normal 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)
|
||||
94
tundra/tools/install-sargo.py
Normal file
94
tundra/tools/install-sargo.py
Normal 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()
|
||||
14
tundra/tools/pack-rootfs.py
Normal file
14
tundra/tools/pack-rootfs.py
Normal 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)
|
||||
38
tundra/tools/persistent_state.py
Normal file
38
tundra/tools/persistent_state.py
Normal 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
|
||||
77
tundra/tools/prepare-droidian.py
Normal file
77
tundra/tools/prepare-droidian.py
Normal 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()
|
||||
82
tundra/tools/qualify-boot-backup.py
Normal file
82
tundra/tools/qualify-boot-backup.py
Normal 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()
|
||||
90
tundra/tools/qualify-network.py
Normal file
90
tundra/tools/qualify-network.py
Normal 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())
|
||||
76
tundra/tools/ramdisk_reference.py
Normal file
76
tundra/tools/ramdisk_reference.py
Normal 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
|
||||
32
tundra/tools/read-sargo-gpt.py
Normal file
32
tundra/tools/read-sargo-gpt.py
Normal 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
45
tundra/tools/read-usb.py
Normal 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)
|
||||
54
tundra/tools/rescue-ssh.py
Normal file
54
tundra/tools/rescue-ssh.py
Normal 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()
|
||||
100
tundra/tools/resolve-manifest.py
Normal file
100
tundra/tools/resolve-manifest.py
Normal 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())
|
||||
74
tundra/tools/run-sargo-native.py
Normal file
74
tundra/tools/run-sargo-native.py
Normal 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
103
tundra/tools/run-vm.py
Normal 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()
|
||||
108
tundra/tools/stage-runtime.py
Normal file
108
tundra/tools/stage-runtime.py
Normal 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()
|
||||
43
tundra/tools/test-audio.py
Normal file
43
tundra/tools/test-audio.py
Normal 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()
|
||||
63
tundra/tools/test-camera-aal.py
Normal file
63
tundra/tools/test-camera-aal.py
Normal 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()
|
||||
62
tundra/tools/test-camera.py
Normal file
62
tundra/tools/test-camera.py
Normal 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()
|
||||
42
tundra/tools/test-rescue-boot.py
Normal file
42
tundra/tools/test-rescue-boot.py
Normal 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
Loading…
Reference in a new issue