mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-28 22:01:49 +01:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04ccdda050 | ||
|
|
e7b037ce88 | ||
|
|
f1c8017873 | ||
|
|
defa6f49f0 | ||
|
|
89e5ee610d | ||
|
|
2b8836929e | ||
|
|
248e5b3382 | ||
|
|
3dcba52e91 | ||
|
|
efda14776d | ||
|
|
6bcce9cbf7 | ||
|
|
8f1a811e1d | ||
|
|
9658f38382 | ||
|
|
eb77fe1d2f | ||
|
|
c733fab6ae |
No files matched your search
@@ -181,7 +181,6 @@ old="$stage/old.app"
|
||||
if mv "$app" "$old"; then
|
||||
if mv "$new" "$app"; then rm -rf "$old"; else mv "$old" "$app"; fi
|
||||
fi
|
||||
xattr -dr com.apple.quarantine "$app" 2>/dev/null
|
||||
rm -rf "$stage"
|
||||
open "$app"
|
||||
`;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# What we have tested
|
||||
|
||||
Current published release: Frame Control **v0.4.52**, 28 October 2026. Rows identify the last direct check of each feature; an older row does not mean the latest installer was retested on that hardware. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. See each link for the exact procedure and limits.
|
||||
Current published release: Frame Control **v0.4.53**, 28 October 2026. Rows identify the last direct check of each feature; an older row does not mean the latest installer was retested on that hardware. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. See each link for the exact procedure and limits.
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
| Mac desktop release — tested on a real Mac | Physical MacBook Air (MacBookAir9,1), Intel Core i3, macOS 15.7.7 (24G720): official v0.4.39 x64 DMG and ZIP hashes/architectures verified; clean DMG app installed and launched. Native UI, preferences across restart, update discovery of 0.4.40, offline OBS Start/Stop and 116-entry Decky store checked. Actual Open at Login menu toggled on, persisted across Quit/relaunch, then restored off. A 54-byte relay upload/readback/delete passed under coordinator/headset locks | No | Ordinary browser quarantine, right-click Open and Open Anyway remain unverified; the synthetic quarantine probe was inconclusive. Actual login startup untested. Direct frame.local had no route and app SSH trust failed; relay transfer is not native Mac transfer acceptance. Real OBS/Framey VR unverified. Earlier shutdown race fix #284 shipped in v0.4.40, whose Intel installer was not tested here | 26 Oct, official v0.4.39 tag `fd0b0a9`: [repeat Intel acceptance evidence](evidence/intel-mac-v0.4.39-2026-10-26.md); [earlier release test](evidence/mac-v0439-2026-10-26.md) |
|
||||
| Core title smoke test | A Linux host ran the v0.4.52 versioned source candidate, whose tree matches the tagged release commit, against the real Frame under a unique lock: 12 steps passed, one optional x86_64 launch was skipped, zero failed; a separate locked cleanup audit passed | No | The headset was in standby and unworn. Steam Linux Runtime 4 was absent, so the x86_64 test executable did not launch. Worn rendering and physical-controller input were not checked | 28 Oct, v0.4.52 tag `v0.4.52`, Frame OS 0.5.6 build `20261022.6153909`: [release verification](evidence/v0452-release-2026-10-28.md), [candidate smoke](evidence/v0452-candidate-2026-10-28.md) |
|
||||
| Core title smoke test | A Linux host ran the v0.4.53 versioned source candidate against the real Frame under a unique lock; only documentation changed before the tagged release, leaving its executable source the same: 12 steps passed, one optional x86_64 launch was skipped, zero failed; a separate locked cleanup audit passed | No | The headset was in standby and unworn. Steam Linux Runtime 4 was absent, so the x86_64 test executable did not launch. Worn rendering and physical-controller input were not checked | 28 Oct, v0.4.53 tag `v0.4.53`, Frame OS 0.5.6 build `20261022.6153909`: [release verification](evidence/v0453-release-2026-10-28.md), [candidate smoke](evidence/v0453-candidate-2026-10-28.md) |
|
||||
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
|
||||
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
|
||||
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
|
||||
@@ -24,7 +24,7 @@ Current published release: Frame Control **v0.4.52**, 28 October 2026. Rows iden
|
||||
| Open at login | No Frame needed; official v0.4.39 on the physical Intel Air toggled the actual native menu on, retained its checkmark after Quit/relaunch and restored it off. Linux autostart file previously toggled on a physical Linux host with an isolated config directory | No | A full OS login session and physical Windows host login remain untested | 26 Oct, v0.4.39: [native Intel toggle evidence](evidence/intel-mac-v0.4.39-2026-10-26.md); Linux v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
|
||||
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
|
||||
| Experimental Frame Control SteamVR panel | Frame OS 0.5.6 registered and served the opt-in overlay. A later unattended check safely woke SteamVR and found the panel in stereo compositor and mirror captures; it removed its temporary service and restored power settings | No | No person wore the headset for that check. Physical controller input, scrolling, VR keyboard, comfort and legibility remain unverified. The large status view has browser and packaging checks, not a wearer review | 26 Oct, v0.4.29–0.4.40: [panel PR #201](https://github.com/saphid/frame-control/pull/201), [unattended evidence](evidence/framey-vr-2026-10-25/README.md), [post-#292 unworn sample exercise](evidence/v0440-release-2026-10-26.md#post-tag-292-panel-regression), [earlier standby-gated exercise](evidence/framey-vr-standby-gate-2026-10-26.md), [worn checklist](framey-vr-check.md) |
|
||||
| Experimental Decky Loader manager | Earlier real Frame checks passed opt-in ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove. The current exact-version catalog records six scoped works: Notebook default drawing, Volume Mixer and Mixer silent per-app volume, Volume Boost muted sink controls, DeckySpy metrics, and Simple Timer silent in-panel timers | No | v0.4.52 classifies 116 exact-version plugins as **6 works / 54 partial / 45 broken / 11 N/A**. “Works” applies only to each documented behavior, never worn VR. Network Sentinel and Battery Tracker are partial; their managed real-Frame ARM64 behavior is unverified. Most other backends, audible output, physical-controller use and wearer-visible official panels remain unverified | 28 Oct, v0.4.52: [exact-version table](framey-compatibility.md), [release verification](evidence/v0452-release-2026-10-28.md), [DeckySpy evidence](evidence/framey-deckyspy-2026-10-26.md), [Simple Timer evidence](evidence/framey-simple-timer-2026-10-26.md), [audio evidence](evidence/framey-audio-arm64-2026-10-26.md) |
|
||||
| Experimental Framey VR plugin panels and store | An unattended Frame OS 0.5.6 exercise showed the repository sample panel in stereo compositor and mirror captures. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file, and confirmed it after a fresh iframe reopen; temporary services were removed and original power settings restored | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. Fake-Frame install/remove and local Chromium plugin initialization do not prove an official plugin is visible or usable in worn VR | 28 Oct, v0.4.52 catalog with earlier 25–26 Oct VR evidence: [unattended sample evidence](evidence/framey-vr-2026-10-25/README.md), [compatibility table](framey-compatibility.md), [worn checklist](framey-vr-check.md) |
|
||||
| Experimental Decky Loader manager | Earlier real Frame checks passed opt-in ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove. The current exact-version catalog records six scoped works: Notebook default drawing, Volume Mixer and Mixer silent per-app volume, Volume Boost muted sink controls, DeckySpy metrics, and Simple Timer silent in-panel timers | No | v0.4.53 classifies 116 exact-version plugins as **6 works / 54 partial / 45 broken / 11 N/A**. “Works” applies only to each documented behavior, never worn VR. Network Sentinel and Battery Tracker are partial; their managed real-Frame ARM64 behavior is unverified. Most other backends, audible output, physical-controller use and wearer-visible official panels remain unverified | 28 Oct, v0.4.53: [exact-version table](framey-compatibility.md), [release verification](evidence/v0453-release-2026-10-28.md), [DeckySpy evidence](evidence/framey-deckyspy-2026-10-26.md), [Simple Timer evidence](evidence/framey-simple-timer-2026-10-26.md), [audio evidence](evidence/framey-audio-arm64-2026-10-26.md) |
|
||||
| Experimental Framey VR plugin panels and store | An unattended Frame OS 0.5.6 exercise showed the repository sample panel in stereo compositor and mirror captures. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file, and confirmed it after a fresh iframe reopen; temporary services were removed and original power settings restored | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. Fake-Frame install/remove and local Chromium plugin initialization do not prove an official plugin is visible or usable in worn VR | 28 Oct, v0.4.53 catalog with earlier 25–26 Oct VR evidence: [unattended sample evidence](evidence/framey-vr-2026-10-25/README.md), [compatibility table](framey-compatibility.md), [worn checklist](framey-vr-check.md) |
|
||||
|
||||
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Free Loader 1.6.0: private Decky backend check
|
||||
|
||||
The official store archive for ID 63 was downloaded and matched SHA-256
|
||||
`05208aee6f6ba824391d8ddc81b1168b2599a9711e7528cdaa789f6cc710b42a`.
|
||||
The published Decky 3.3.0 amd64 loader matched SHA-256
|
||||
`e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181`.
|
||||
|
||||
An unprivileged, disposable Debian container ran that loader and the exact
|
||||
official plugin with no network, host devices, host session or writable host
|
||||
mount. Decky discovered Free Loader as enabled. Its API 1 methods returned 14
|
||||
default settings and an empty local deals map. A private `larger_text` setting
|
||||
changed to `true` and read back; startup created only the container's private
|
||||
settings file, and `read_deals` created an empty private deals cache. No feed,
|
||||
notification, Steam store or external account method was called. The local
|
||||
probe exited successfully; its container was removed. The committed native
|
||||
Decky runtime case repeats these calls and also checks persistence after a
|
||||
loader restart on the CI runner's native architecture. The local x86_64 run
|
||||
passed all 14 runtime cases, including Free Loader and the existing negative
|
||||
regressions. The runner removed its temporary containers and image.
|
||||
|
||||
This strengthens the **partial** label. It does not verify a live feed refresh,
|
||||
notification delivery, real Frame ARM64 service, rendered deal cards, worn VR
|
||||
or controller interaction. The earlier opaque Chromium check had denied backend
|
||||
and network calls, so it could not establish that deal cards render with live
|
||||
data. No headset was touched for this check.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Repeatable installer audit command, 28 October 2026
|
||||
|
||||
The independent installer check used for v0.4.53 was previously a local
|
||||
one-off script. The new script at scripts/audit-release-assets.py makes that
|
||||
check repeatable before future releases are published. It reads the release
|
||||
by exact tag or the draft's exact version title, requires ten installers plus
|
||||
SHA256SUMS, downloads each by asset ID, checks size and SHA-256 against GitHub
|
||||
metadata, matches the downloaded checksum file byte for byte, and inspects
|
||||
package formats and versions. On an x86_64 Linux host, its optional flag also
|
||||
launches the tagged x86_64 AppImage under isolated Xvfb.
|
||||
|
||||
The command writes a JSON report only after all checks pass and refuses to
|
||||
overwrite an existing report. It performs no headset operation and makes no
|
||||
GitHub changes. It does not prove physical Mac or Windows installer launch.
|
||||
|
||||
## Verification
|
||||
|
||||
- Eight focused offline tests passed, including incomplete metadata, wrong
|
||||
Mac bundle version, wrong AppImage architecture, a bad downloaded checksum
|
||||
file, and refusal to overwrite a report. Three existing checksum-generator
|
||||
tests passed.
|
||||
- After v0.4.53 publication, the new command audited its public release with
|
||||
the tag v0.4.53, report path /tmp/frame-v0453-reusable-audit.json, and the
|
||||
--launch-x86-appimage option. It exited successfully after independently
|
||||
downloading all ten installers. Their names, sizes, hashes and package
|
||||
checks matched the [prepublication asset audit](v0453-assets-2026-10-28.json),
|
||||
and the downloaded SHA256SUMS had the same SHA-256:
|
||||
813441ac5c121e2c9effd274576aa6db1ff01187f811463a1014f86e0172608c.
|
||||
The x86_64 AppImage's bundled server returned HTTP 200 with FrameControl/1
|
||||
and stopped after app exit. The local JSON report had SHA-256
|
||||
ad8d7feaf6c0398b216f43c00206dd01d6efc714e3b341048bece96c0e5be376.
|
||||
|
||||
This postpublication repeat verifies the new command; it does not replace
|
||||
the prepublication v0.4.53 release gates recorded in
|
||||
[release verification](v0453-release-2026-10-28.md).
|
||||
@@ -25,7 +25,7 @@ python3 scripts/framey-audit-browser.py --cache /tmp/framey-archives
|
||||
|
||||
Open the browser audit URL in local Chromium, run `runAudit()` and inspect/download `results`. All backend/network actions remain denied.
|
||||
|
||||
A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates-2026-10-26.md) accepts only byte-identical duplicate ZIP members whose modes differ at most in execute bits. The exact archive passed private local and focused fake-Frame install/remove; its frontend rendered in opaque Chromium and isolated Python settings/empty-deals reads passed with a Decky stub. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. Actual Decky, feed updates and worn VR remain unverified.
|
||||
A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates-2026-10-26.md) accepts only byte-identical duplicate ZIP members whose modes differ at most in execute bits. The exact archive passed private local and focused fake-Frame install/remove; its frontend rendered in opaque Chromium. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. A later [native Decky 3.3.0 check](evidence/framey-free-loader-native-2026-10-28.md) verified plugin discovery, default settings, empty local deals and private setting persistence after restart in a network-isolated amd64 container. Live feed updates, notifications, Steam store integration, real Frame ARM64 service and worn VR remain unverified.
|
||||
|
||||
A [2026-10-28 static action-limit review](evidence/framey-static-action-limits-2026-10-28.md) hash-checked the exact AutoSuspend and CheatDeck archives. AutoSuspend contains Steam suspend/shutdown paths, and CheatDeck's advertised trainer flow needs a Steam game context menu and launch-option writes. Neither plugin was executed; both retain their broken labels.
|
||||
|
||||
@@ -81,7 +81,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 60: DeckyFileServer 1.2.0 | 1 / 2 | partial | Managed install supplies a native ARM64 helper and removes the exact archive's unnecessary root request. Real Decky 3.3.0 on Frame passes private read-only HTTPS listing/download and upload denial. Frontend address/picker limitations remain; use HTTPS and a preconfigured directory. Uploads, generated thumbnails and worn VR are unverified. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame, real Decky 3.3.0 amd64, real Frame SSH |
|
||||
| 61: TabMaster 2.15.1 | 1 / 2 | partial | Local Chromium rendered the QAM and navigable Overview/Filters documentation with an actual Decky 3.3.0 amd64 backend. Library-tab editing remains unavailable: Steam login/library patches and MobX reactions have no target. ARM64 and worn VR unverified. | static archive, local browser, fake-Frame |
|
||||
| 62: TS3 QuickAccess 1.2.0 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: ts3-qs4sd/bin/ts3-qs4sd.so. | static archive, local browser, fake-Frame |
|
||||
| 63: Free Loader 1.6.0 | 1 / 2 | partial | The exact archive's duplicate names contain identical bytes and differ only in execute bits; the guarded installer now accepts them, and a private local install/remove transaction passed. The current opaque Chromium host renders Free Games but cannot load deals with backend/network denied. Exact official Python settings and empty local-deals methods passed in a no-network container with a Decky stub. Real Decky, feed refresh, notifications and worn VR remain unverified. | static archive, local browser, fake-Frame pinned install/remove, local exact-archive transaction, isolated Python backend |
|
||||
| 63: Free Loader 1.6.0 | 1 / 2 | partial | The exact archive's byte-identical duplicate ZIP members pass guarded install/remove. Actual Decky 3.3.0 in a private network-isolated container discovers the plugin, reads default settings and empty local deals, and retains a settings change after restart. Live deal-feed refresh, notifications, Steam store integration, real Frame ARM64 service and worn VR remain unverified; no feed request was made. | static archive, local browser, fake-Frame pinned install/remove, local exact-archive transaction, isolated Python backend, isolated Linux backend |
|
||||
| 64: Syncthing 0.2.7 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
|
||||
| 65: Decky Terminal 0.5.3 | 1 / 2 | partial | Local routes initialize after parameter-route support; no shell commands were run. | static archive, local browser, fake-Frame |
|
||||
| 66: PlayTime 3.2.5 | 1 / 2 | partial | Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. Running snapshots are now exposed, but its private MobX observation is unsupported; automatic recording and ARM64 backend remain unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
|
||||
+25
-3
@@ -27,8 +27,22 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
temporary profile. This CI test does not test a physical display, FUSE mount
|
||||
or a headset.
|
||||
|
||||
2. Download the draft's installers and test them. An installed copy of the
|
||||
previous version won't offer the draft, so install it directly.
|
||||
2. Independently download and inspect the draft's installers on an x86_64
|
||||
Linux host with `gh`, `dpkg-deb` and `xvfb-run` installed:
|
||||
|
||||
```sh
|
||||
scripts/audit-release-assets.py v0.4.0 \
|
||||
--report /tmp/frame-v0.4.0-assets.json --launch-x86-appimage
|
||||
```
|
||||
|
||||
The command downloads each of the ten installers by asset ID, verifies its
|
||||
size and SHA-256 against GitHub metadata, matches the downloaded
|
||||
`SHA256SUMS` byte for byte, checks package formats and versions, and starts
|
||||
the x86_64 AppImage with a temporary profile and isolated Xvfb display. It
|
||||
writes the report only after all checks pass. This does not prove that the
|
||||
Mac or Windows installers launch on physical machines. Test those packages
|
||||
separately when the required machines are available. An installed copy of
|
||||
the previous version won't offer the draft, so install it directly.
|
||||
|
||||
3. Write the release notes on the draft. The update banner links to them.
|
||||
|
||||
@@ -58,7 +72,9 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
that the uploaded `SHA256SUMS` digest matches a fresh list of those ten
|
||||
installer digests. It attaches
|
||||
`update.json` (the version, the notes, the release page and each
|
||||
installer's digest), then publishes the release and marks it latest. It
|
||||
installer's digest), verifies the uploaded asset's SHA-256 and downloads
|
||||
its bytes for an exact readback before publishing the release and marking
|
||||
it latest. It
|
||||
refuses an already published release before replacing its live update
|
||||
manifest; use a new tested release for changes after publication. It
|
||||
uses only the REST API: `gh release view` can't find a draft whose
|
||||
@@ -70,6 +86,12 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
seconds after starting, then every 6 hours, and anyone can use **Check for
|
||||
Updates…** (the app menu on macOS, the Help menu elsewhere).
|
||||
|
||||
After publication, rerun `audit-release-assets.py` with a new report path.
|
||||
For a published release it also downloads `update.json`, verifies its
|
||||
GitHub SHA-256, and checks that the offered version, release page, notes,
|
||||
installer names, sizes, and digests match that release. This checks the
|
||||
updater channel's published bytes, which the draft audit cannot inspect.
|
||||
|
||||
To pull a bad release, mark the previous one as latest
|
||||
(`gh release edit v0.3.9 --latest`) or turn the bad one back into a draft.
|
||||
Copies that already updated stay on it. Nothing downgrades them.
|
||||
|
||||
Executable
+275
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independently download and inspect every installer in a GitHub release.
|
||||
|
||||
Run on Linux after the tagged workflow finishes and before publishing its
|
||||
draft. The JSON report is written only after every check passes.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import platform
|
||||
import plistlib
|
||||
import re
|
||||
import runpy
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO = "saphid/frame-control"
|
||||
SCRIPTS = Path(__file__).resolve().parent
|
||||
CHECKSUMS = runpy.run_path(str(SCRIPTS / "release-checksums.py"))
|
||||
INSTALLERS = CHECKSUMS["INSTALLERS"]
|
||||
|
||||
|
||||
class AuditError(Exception):
|
||||
"""Release asset metadata or downloaded bytes failed a check."""
|
||||
|
||||
|
||||
def github_json(path):
|
||||
result = subprocess.run(
|
||||
["gh", "api", f"repos/{REPO}/{path}"],
|
||||
capture_output=True, text=True, check=False, timeout=60,
|
||||
)
|
||||
if result.returncode:
|
||||
raise AuditError(f"GitHub read failed for {path}: {result.stderr.strip()}")
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as error:
|
||||
raise AuditError(f"invalid GitHub JSON for {path}: {error}") from error
|
||||
|
||||
|
||||
def find_release(tag):
|
||||
releases = []
|
||||
for page in range(1, 21):
|
||||
batch = github_json(f"releases?per_page=100&page={page}")
|
||||
if not isinstance(batch, list):
|
||||
raise AuditError("GitHub release list is not an array")
|
||||
releases.extend(batch)
|
||||
try:
|
||||
return CHECKSUMS["find_release"](
|
||||
(json.dumps(item) for item in releases), tag
|
||||
)
|
||||
except ValueError as error:
|
||||
if "found 0" not in str(error):
|
||||
raise AuditError(str(error)) from error
|
||||
if len(batch) < 100:
|
||||
break
|
||||
raise AuditError(f"no unique release found for {tag}")
|
||||
|
||||
|
||||
def validate_assets(release):
|
||||
assets = release.get("assets")
|
||||
expected = set(INSTALLERS) | {"SHA256SUMS"}
|
||||
if release.get("draft") is False:
|
||||
expected.add("update.json")
|
||||
if not isinstance(assets, list) or len(assets) != len(expected):
|
||||
raise AuditError(f"expected exactly {len(expected)} release assets")
|
||||
by_name = {asset.get("name"): asset for asset in assets if isinstance(asset, dict)}
|
||||
if set(by_name) != expected or len(by_name) != len(assets):
|
||||
raise AuditError("release has missing, duplicate, or unexpected assets")
|
||||
for name, asset in by_name.items():
|
||||
if (not isinstance(asset.get("id"), int) or asset["id"] <= 0
|
||||
or not isinstance(asset.get("size"), int) or asset["size"] <= 0
|
||||
or not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", asset.get("digest") or "")):
|
||||
raise AuditError(f"{name} has no valid ID, size, or SHA-256 digest")
|
||||
try:
|
||||
expected_sums = CHECKSUMS["checksums"](release).encode("ascii")
|
||||
except ValueError as error:
|
||||
raise AuditError(str(error)) from error
|
||||
return by_name, expected_sums
|
||||
|
||||
|
||||
def sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for block in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def download_asset(asset, path):
|
||||
with path.open("wb") as target:
|
||||
result = subprocess.run(
|
||||
["gh", "api", "-H", "Accept: application/octet-stream",
|
||||
f"repos/{REPO}/releases/assets/{asset['id']}"],
|
||||
stdout=target, stderr=subprocess.PIPE, check=False, timeout=1800,
|
||||
)
|
||||
if result.returncode:
|
||||
raise AuditError(f"download failed for {asset['name']}: "
|
||||
f"{result.stderr.decode(errors='replace').strip()}")
|
||||
if path.stat().st_size != asset["size"]:
|
||||
raise AuditError(f"{asset['name']} size differs from GitHub metadata")
|
||||
digest = sha256(path)
|
||||
if digest != asset["digest"][7:].lower():
|
||||
raise AuditError(f"{asset['name']} SHA-256 differs from GitHub metadata")
|
||||
return digest
|
||||
|
||||
|
||||
def validate_update_manifest(path, release):
|
||||
"""Check the bytes running copies read against the published release."""
|
||||
try:
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise AuditError(f"update.json is not valid UTF-8 JSON: {error}") from error
|
||||
if not isinstance(manifest, dict):
|
||||
raise AuditError("update.json is not an object")
|
||||
tag = release.get("tag_name")
|
||||
if not isinstance(tag, str) or not re.fullmatch(r"v\d+\.\d+\.\d+", tag):
|
||||
raise AuditError("published release has an invalid tag")
|
||||
if manifest.get("version") != tag[1:]:
|
||||
raise AuditError("update.json version differs from the release tag")
|
||||
page = f"https://github.com/{REPO}/releases/tag/{tag}"
|
||||
if manifest.get("page") != page:
|
||||
raise AuditError("update.json release page differs from the tag page")
|
||||
if manifest.get("notes") != (release.get("body") or "")[:4000]:
|
||||
raise AuditError("update.json notes differ from the release notes")
|
||||
expected = {asset["name"]: {key: asset[key] for key in ("name", "size", "digest")}
|
||||
for asset in release["assets"] if asset["name"] in INSTALLERS}
|
||||
assets = manifest.get("assets")
|
||||
if not isinstance(assets, list) or len(assets) != len(expected):
|
||||
raise AuditError("update.json has an incomplete installer list")
|
||||
names = [asset.get("name") for asset in assets if isinstance(asset, dict)]
|
||||
if (len(names) != len(assets) or not all(isinstance(name, str) for name in names)
|
||||
or len(names) != len(set(names))):
|
||||
raise AuditError("update.json has invalid or duplicate installer entries")
|
||||
if {asset["name"]: asset for asset in assets} != expected:
|
||||
raise AuditError("update.json installers differ from the release assets")
|
||||
return {"version": manifest["version"], "installers": len(assets)}
|
||||
|
||||
|
||||
def inspect_package(path, name, version):
|
||||
if name.endswith(".zip"):
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
bad = archive.testzip()
|
||||
if bad is not None:
|
||||
raise AuditError(f"{name} has a bad ZIP member: {bad}")
|
||||
detail = {"zipCrc": "ok", "members": len(archive.infolist())}
|
||||
if "-mac-" in name:
|
||||
matches = [entry for entry in archive.namelist()
|
||||
if entry.endswith("Frame Control.app/Contents/Info.plist")]
|
||||
if len(matches) != 1:
|
||||
raise AuditError(f"{name} has {len(matches)} app Info.plist files")
|
||||
found = plistlib.loads(archive.read(matches[0])).get("CFBundleShortVersionString")
|
||||
if found != version:
|
||||
raise AuditError(f"{name} bundle version {found!r} is not {version}")
|
||||
detail["bundleVersion"] = found
|
||||
return detail
|
||||
if name.endswith(".deb"):
|
||||
result = subprocess.run(
|
||||
["dpkg-deb", "-f", str(path), "Version", "Architecture"],
|
||||
capture_output=True, text=True, check=False, timeout=60,
|
||||
)
|
||||
fields = [line.partition(":")[2].strip() for line in result.stdout.splitlines()]
|
||||
architecture = "arm64" if "arm64" in name else "amd64"
|
||||
if result.returncode or fields != [version, architecture]:
|
||||
raise AuditError(f"{name} DEB version/architecture mismatch: {fields}")
|
||||
return {"debVersion": fields[0], "architecture": fields[1]}
|
||||
if name.endswith(".AppImage"):
|
||||
with path.open("rb") as source:
|
||||
head = source.read(20)
|
||||
machine = struct.unpack("<H", head[18:20])[0] if len(head) >= 20 else None
|
||||
expected = 183 if "arm64" in name else 62
|
||||
if head[:4] != b"\x7fELF" or machine != expected:
|
||||
raise AuditError(f"{name} ELF machine {machine!r} is not {expected}")
|
||||
return {"elfMachine": machine}
|
||||
if name.endswith(".dmg"):
|
||||
if path.stat().st_size < 512:
|
||||
raise AuditError(f"{name} is too small for a UDIF trailer")
|
||||
with path.open("rb") as source:
|
||||
source.seek(-512, 2)
|
||||
trailer = source.read(512)
|
||||
if trailer[:4] != b"koly":
|
||||
raise AuditError(f"{name} has no UDIF trailer")
|
||||
return {"udifTrailer": "ok"}
|
||||
if name.endswith(".exe"):
|
||||
with path.open("rb") as source:
|
||||
head = source.read(64)
|
||||
if len(head) < 64 or head[:2] != b"MZ":
|
||||
raise AuditError(f"{name} has no MZ header")
|
||||
source.seek(struct.unpack("<I", head[60:64])[0])
|
||||
pe = source.read(6)
|
||||
machine = struct.unpack("<H", pe[4:6])[0] if len(pe) == 6 else None
|
||||
if pe[:4] != b"PE\x00\x00" or machine not in (0x14C, 0x8664):
|
||||
raise AuditError(f"{name} has an invalid PE header or machine")
|
||||
return {"peMachine": machine}
|
||||
raise AuditError(f"no package inspection for {name}")
|
||||
|
||||
|
||||
def audit(tag, report_path, launch_x86_appimage=False):
|
||||
if not re.fullmatch(r"v\d+\.\d+\.\d+", tag):
|
||||
raise AuditError("expected a version tag such as v0.4.54")
|
||||
if report_path.exists():
|
||||
raise AuditError(f"report already exists; choose a new path: {report_path}")
|
||||
if not report_path.parent.is_dir():
|
||||
raise AuditError(f"report directory does not exist: {report_path.parent}")
|
||||
if not shutil.which("gh") or not shutil.which("dpkg-deb"):
|
||||
raise AuditError("gh and dpkg-deb are required on the audit host")
|
||||
if launch_x86_appimage and not shutil.which("xvfb-run"):
|
||||
raise AuditError("xvfb-run is required for --launch-x86-appimage")
|
||||
if launch_x86_appimage and platform.machine().lower() not in ("x86_64", "amd64"):
|
||||
raise AuditError("--launch-x86-appimage requires an x86_64 audit host")
|
||||
release = find_release(tag)
|
||||
by_name, expected_sums = validate_assets(release)
|
||||
with tempfile.TemporaryDirectory(prefix=f"frame-{tag}-assets-") as directory:
|
||||
scratch = Path(directory)
|
||||
sums_path = scratch / "SHA256SUMS"
|
||||
sums_hash = download_asset(by_name["SHA256SUMS"], sums_path)
|
||||
if sums_path.read_bytes() != expected_sums:
|
||||
raise AuditError("SHA256SUMS bytes differ from the ten GitHub asset digests")
|
||||
manifest_detail = None
|
||||
if not release.get("draft"):
|
||||
manifest_path = scratch / "update.json"
|
||||
manifest_digest = download_asset(by_name["update.json"], manifest_path)
|
||||
manifest_detail = validate_update_manifest(manifest_path, release)
|
||||
manifest_detail["sha256"] = manifest_digest
|
||||
rows = []
|
||||
for name in INSTALLERS:
|
||||
path = scratch / name
|
||||
asset = by_name[name]
|
||||
digest = download_asset(asset, path)
|
||||
detail = inspect_package(path, name, tag[1:])
|
||||
if launch_x86_appimage and name == "Frame-Control-linux-x86_64.AppImage":
|
||||
path.chmod(0o755)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPTS / "check-appimage-launch.py"), str(path)],
|
||||
capture_output=True, text=True, check=False, timeout=120,
|
||||
)
|
||||
if result.returncode or digest not in result.stdout:
|
||||
raise AuditError(f"isolated AppImage launch failed: {result.stderr.strip()}")
|
||||
detail["appimageLaunch"] = "HTTP 200; FrameControl/1; server stopped"
|
||||
rows.append({"name": name, "size": asset["size"], "sha256": digest, **detail})
|
||||
path.unlink()
|
||||
print(f"verified {name}", flush=True)
|
||||
report = {"tag": tag, "releaseId": release["id"],
|
||||
"tagNameAtAudit": release.get("tag_name"), "draftAtAudit": release.get("draft"),
|
||||
"sha256sumsSha256": sums_hash, "updateManifest": manifest_detail,
|
||||
"assets": rows}
|
||||
with report_path.open("x", encoding="utf-8") as output:
|
||||
json.dump(report, output, indent=2)
|
||||
output.write("\n")
|
||||
print(f"verified {len(rows)} installer downloads; report {report_path}")
|
||||
return report
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("tag", help="release tag, for example v0.4.54")
|
||||
parser.add_argument("--report", required=True, type=Path, help="new JSON report path")
|
||||
parser.add_argument("--launch-x86-appimage", action="store_true",
|
||||
help="also launch the tagged x86_64 AppImage in isolated Xvfb")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
audit(args.tag, args.report, args.launch_x86_appimage)
|
||||
except (AuditError, OSError, subprocess.TimeoutExpired,
|
||||
zipfile.BadZipFile, plistlib.InvalidFileException) as error:
|
||||
parser.exit(1, f"asset audit failed: {error}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -40,7 +40,7 @@ def render(data):
|
||||
'python3 scripts/framey-audit-browser.py --cache /tmp/framey-archives',
|
||||
'```', '',
|
||||
'Open the browser audit URL in local Chromium, run `runAudit()` and inspect/download `results`. All backend/network actions remain denied.', '',
|
||||
'A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates-2026-10-26.md) accepts only byte-identical duplicate ZIP members whose modes differ at most in execute bits. The exact archive passed private local and focused fake-Frame install/remove; its frontend rendered in opaque Chromium and isolated Python settings/empty-deals reads passed with a Decky stub. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. Actual Decky, feed updates and worn VR remain unverified.', '',
|
||||
'A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates-2026-10-26.md) accepts only byte-identical duplicate ZIP members whose modes differ at most in execute bits. The exact archive passed private local and focused fake-Frame install/remove; its frontend rendered in opaque Chromium. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. A later [native Decky 3.3.0 check](evidence/framey-free-loader-native-2026-10-28.md) verified plugin discovery, default settings, empty local deals and private setting persistence after restart in a network-isolated amd64 container. Live feed updates, notifications, Steam store integration, real Frame ARM64 service and worn VR remain unverified.', '',
|
||||
'A [2026-10-28 static action-limit review](evidence/framey-static-action-limits-2026-10-28.md) hash-checked the exact AutoSuspend and CheatDeck archives. AutoSuspend contains Steam suspend/shutdown paths, and CheatDeck\'s advertised trainer flow needs a Steam game context menu and launch-option writes. Neither plugin was executed; both retain their broken labels.', '',
|
||||
'A [Network Sentinel user-service check](evidence/framey-network-sentinel-2026-10-28.md) adapts only the exact 1.0.4 digest. In isolated actual Decky 3.3.0 amd64, the pinned plugin-local psutil wheel, private settings RPC and one monitoring-loop sample pass. External ping/DNS, Frame ARM64, panel controls and worn VR remain unverified; this is partial compatibility.', '',
|
||||
'A [Battery Tracker user-service check](evidence/framey-battery-tracker-2026-10-28.md) adapts only the exact 0.2.0 digest. A locked read-only Frame probe confirmed a Battery-type supply with the required fields; actual Decky 3.3.0 amd64 recorded private Frame-shaped samples and returned history/per-app power. The managed plugin has not run on the real Frame, and panel controls and worn VR remain unverified; this is partial compatibility.', '',
|
||||
|
||||
@@ -139,7 +139,23 @@ for asset in $old; do
|
||||
gh api -X DELETE "repos/$repo/releases/assets/$asset" >/dev/null
|
||||
done
|
||||
gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name=update.json" \
|
||||
-H "Content-Type: application/json" --input "$tmp/update.json" >/dev/null
|
||||
-H "Content-Type: application/json" --input "$tmp/update.json" > "$tmp/uploaded-manifest.json"
|
||||
asset_id=$(python3 - "$tmp/uploaded-manifest.json" "$tmp/update.json" <<'EOF'
|
||||
import hashlib, json, pathlib, sys
|
||||
asset = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
body = pathlib.Path(sys.argv[2]).read_bytes()
|
||||
digest = "sha256:" + hashlib.sha256(body).hexdigest()
|
||||
if (asset.get("name") != "update.json" or not isinstance(asset.get("id"), int)
|
||||
or asset["id"] <= 0 or asset.get("size") != len(body)
|
||||
or asset.get("digest") != digest):
|
||||
sys.exit("not publishing: uploaded update.json metadata differs from its source bytes")
|
||||
print(asset["id"])
|
||||
EOF
|
||||
)
|
||||
gh api -H "Accept: application/octet-stream" "repos/$repo/releases/assets/$asset_id" > "$tmp/update-readback.json"
|
||||
cmp -s "$tmp/update.json" "$tmp/update-readback.json" || {
|
||||
echo "not publishing: downloaded update.json differs from its source bytes" >&2; exit 1;
|
||||
}
|
||||
echo "ok update.json"
|
||||
|
||||
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -f body="$(cat "$tmp/release-body.txt")" -F draft=false -F prerelease=false \
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
"shim": null,
|
||||
"probe": "toggle-settings"
|
||||
},
|
||||
{
|
||||
"id": 63,
|
||||
"shim": null,
|
||||
"probe": "free-loader"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"shim": "music-control-system-dbus-env",
|
||||
|
||||
@@ -240,6 +240,30 @@ def main():
|
||||
report['result'] = ('original empty argument bug reproduced: thumbnail policy ignored'
|
||||
if args.reproduce_fileserver_empty_flag_regression else
|
||||
'discovery, API1 settings, native HTTPS listing/download, thumbnail policy and stop passed; uploads disabled')
|
||||
elif case['probe'] == 'free-loader':
|
||||
settings = current('settings_read')
|
||||
assert settings['notify_on_free_games'] is True, settings
|
||||
assert settings['larger_text'] is False, settings
|
||||
assert current('read_deals') == {}
|
||||
# This plugin commits defaults on startup and writes an empty
|
||||
# deals cache on first read. Both paths are private to this
|
||||
# disposable container; no feed, notification or store call.
|
||||
settings_dir = base/'settings'/record['folder']
|
||||
assert (settings_dir/'settings.json').is_file()
|
||||
assert (settings_dir/'deal_db.json').is_file()
|
||||
current('settings_setSetting', 'larger_text', True)
|
||||
assert current('settings_read')['larger_text'] is True
|
||||
os.killpg(backend.pid, signal.SIGTERM)
|
||||
backend.wait(timeout=8)
|
||||
backend = subprocess.Popen([str(loader)], env=env, stdout=log, stderr=log,
|
||||
start_new_session=True)
|
||||
processes.append(backend)
|
||||
until_ready(lambda: rpc('loader/get_plugins'), 'restarted Decky discovery')
|
||||
assert until_ready(lambda: current('settings_read')['larger_text'],
|
||||
'private Free Loader setting after restart') is True
|
||||
assert current('read_deals') == {}
|
||||
report['result'] = ('discovery, default settings, empty local deals, '
|
||||
'private setting write/read and restart persistence passed; feed, notifications and VR unverified')
|
||||
elif case['probe'] == 'toggle-settings':
|
||||
def call(method, *values):
|
||||
result = rpc('loader/call_plugin_method', record['name'], method, *values)
|
||||
|
||||
+11
-3
@@ -9,6 +9,7 @@ $FAKEGH_NEW_ID (default 9001) without adding it to the listing, like GitHub's li
|
||||
lagging a fresh draft. With jq installed, --jq filters a response the way gh does
|
||||
(raw strings, compact JSON)."""
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -120,10 +121,17 @@ elif method == "POST" and endpoint == repo + "releases":
|
||||
answer(new, json.dumps(new))
|
||||
elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"):
|
||||
pass
|
||||
elif method == "GET" and endpoint == repo + "releases/assets/901":
|
||||
data = open(os.environ["FAKEGH_UPLOAD"], "rb").read()
|
||||
if os.environ.get("FAKEGH_CORRUPT_MANIFEST_READBACK") == "1":
|
||||
data += b" "
|
||||
sys.stdout.buffer.write(data)
|
||||
elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"):
|
||||
with open(inp) as src, open(os.environ["FAKEGH_UPLOAD"], "w") as dst:
|
||||
dst.write(src.read())
|
||||
print("{}")
|
||||
data = open(inp, "rb").read()
|
||||
with open(os.environ["FAKEGH_UPLOAD"], "wb") as dst:
|
||||
dst.write(data)
|
||||
print(json.dumps({"id": 901, "name": "update.json", "size": len(data),
|
||||
"digest": "sha256:" + hashlib.sha256(data).hexdigest()}))
|
||||
elif method == "PATCH" and endpoint.startswith(repo + "releases/"):
|
||||
rid = endpoint[len(repo + "releases/"):]
|
||||
if not rid.isdigit(): # what GitHub said to v0.4.2's PATCH of releases/ (an empty id)
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Independent release audit rejects incomplete metadata and wrong package versions."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import plistlib
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "audit-release-assets.py"
|
||||
spec = importlib.util.spec_from_file_location("audit_release_assets", SCRIPT)
|
||||
audit = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(audit)
|
||||
|
||||
|
||||
def release_assets():
|
||||
assets = []
|
||||
for number, name in enumerate(audit.INSTALLERS, 1):
|
||||
assets.append({
|
||||
"id": number, "name": name, "size": number,
|
||||
"digest": "sha256:" + hashlib.sha256(str(number).encode()).hexdigest(),
|
||||
})
|
||||
sums = audit.CHECKSUMS["checksums"]({"assets": assets}).encode("ascii")
|
||||
assets.append({
|
||||
"id": 99, "name": "SHA256SUMS", "size": len(sums),
|
||||
"digest": "sha256:" + hashlib.sha256(sums).hexdigest(),
|
||||
})
|
||||
return assets, sums
|
||||
|
||||
|
||||
class AssetAudit(unittest.TestCase):
|
||||
def test_published_manifest_matches_release_bytes(self):
|
||||
assets, _ = release_assets()
|
||||
release = {"tag_name": "v9.8.7", "body": "Release notes", "assets": assets}
|
||||
manifest = {
|
||||
"version": "9.8.7",
|
||||
"page": "https://github.com/saphid/frame-control/releases/tag/v9.8.7",
|
||||
"notes": "Release notes",
|
||||
"assets": [{key: asset[key] for key in ("name", "size", "digest")}
|
||||
for asset in assets if asset["name"] in audit.INSTALLERS],
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "update.json"
|
||||
path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
self.assertEqual(audit.validate_update_manifest(path, release),
|
||||
{"version": "9.8.7", "installers": 10})
|
||||
for change in (
|
||||
{"version": "9.8.6"},
|
||||
{"page": "https://github.com/elsewhere/releases/tag/v9.8.7"},
|
||||
{"notes": "stale notes"},
|
||||
{"assets": manifest["assets"][:-1]},
|
||||
{"assets": [dict(manifest["assets"][0], digest="sha256:" + "0" * 64),
|
||||
*manifest["assets"][1:]]},
|
||||
):
|
||||
with self.subTest(change=change):
|
||||
path.write_text(json.dumps({**manifest, **change}), encoding="utf-8")
|
||||
with self.assertRaises(audit.AuditError):
|
||||
audit.validate_update_manifest(path, release)
|
||||
|
||||
def test_complete_metadata_uses_release_checksum_order(self):
|
||||
assets, sums = release_assets()
|
||||
by_name, expected = audit.validate_assets({"assets": assets})
|
||||
self.assertEqual(len(by_name), 11)
|
||||
self.assertEqual(expected, sums)
|
||||
|
||||
def test_published_release_requires_update_manifest(self):
|
||||
assets, sums = release_assets()
|
||||
manifest = {"id": 100, "name": "update.json", "size": 20,
|
||||
"digest": "sha256:" + "a" * 64}
|
||||
with self.assertRaises(audit.AuditError):
|
||||
audit.validate_assets({"draft": False, "assets": assets})
|
||||
by_name, expected = audit.validate_assets({"draft": False, "assets": [*assets, manifest]})
|
||||
self.assertEqual(len(by_name), 12)
|
||||
self.assertEqual(expected, sums)
|
||||
|
||||
def test_missing_duplicate_and_unhashed_assets_fail(self):
|
||||
assets, _ = release_assets()
|
||||
variants = (
|
||||
assets[:-1],
|
||||
[*assets, assets[0]],
|
||||
[dict(assets[0], digest="sha256:bad"), *assets[1:]],
|
||||
[dict(assets[0], size=0), *assets[1:]],
|
||||
)
|
||||
for changed in variants:
|
||||
with self.subTest(changed=changed[0]), self.assertRaises(audit.AuditError):
|
||||
audit.validate_assets({"assets": changed})
|
||||
|
||||
def test_mac_zip_bundle_version_must_match_tag(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "mac.zip"
|
||||
with zipfile.ZipFile(path, "w") as archive:
|
||||
archive.writestr(
|
||||
"Frame Control.app/Contents/Info.plist",
|
||||
plistlib.dumps({"CFBundleShortVersionString": "9.8.7"}),
|
||||
)
|
||||
self.assertEqual(
|
||||
audit.inspect_package(path, "Frame-Control-mac-arm64.zip", "9.8.7")["bundleVersion"],
|
||||
"9.8.7",
|
||||
)
|
||||
with self.assertRaises(audit.AuditError):
|
||||
audit.inspect_package(path, "Frame-Control-mac-arm64.zip", "9.8.8")
|
||||
|
||||
def test_appimage_machine_must_match_name(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "image"
|
||||
path.write_bytes(b"\x7fELF" + bytes(14) + struct.pack("<H", 183))
|
||||
self.assertEqual(
|
||||
audit.inspect_package(path, "Frame-Control-linux-arm64.AppImage", "9.8.7"),
|
||||
{"elfMachine": 183},
|
||||
)
|
||||
with self.assertRaises(audit.AuditError):
|
||||
audit.inspect_package(path, "Frame-Control-linux-x86_64.AppImage", "9.8.7")
|
||||
|
||||
def test_existing_report_is_never_overwritten(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "report.json"
|
||||
path.write_text("keep")
|
||||
with self.assertRaisesRegex(audit.AuditError, "already exists"):
|
||||
audit.audit("v9.8.7", path)
|
||||
self.assertEqual(path.read_text(), "keep")
|
||||
|
||||
def test_missing_report_directory_fails_before_download(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "missing" / "report.json"
|
||||
with self.assertRaisesRegex(audit.AuditError, "directory does not exist"):
|
||||
audit.audit("v9.8.7", path)
|
||||
|
||||
def test_bad_downloaded_checksum_file_creates_no_report(self):
|
||||
assets, _ = release_assets()
|
||||
release = {"id": 42, "tag_name": "v9.8.7", "draft": True, "assets": assets}
|
||||
|
||||
def bad_download(asset, path):
|
||||
path.write_bytes(b"wrong checksum file\n")
|
||||
return asset["digest"][7:]
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
report = Path(directory) / "report.json"
|
||||
with mock.patch.object(audit, "find_release", return_value=release), \
|
||||
mock.patch.object(audit, "download_asset", side_effect=bad_download), \
|
||||
mock.patch.object(audit.shutil, "which", return_value="/fake/tool"):
|
||||
with self.assertRaisesRegex(audit.AuditError, "SHA256SUMS bytes differ"):
|
||||
audit.audit("v9.8.7", report)
|
||||
self.assertFalse(report.exists())
|
||||
|
||||
def test_bad_published_manifest_creates_no_report(self):
|
||||
assets, sums = release_assets()
|
||||
assets.append({"id": 100, "name": "update.json", "size": 2,
|
||||
"digest": "sha256:" + hashlib.sha256(b"{}").hexdigest()})
|
||||
release = {"id": 42, "tag_name": "v9.8.7", "draft": False,
|
||||
"body": "Release notes", "assets": assets}
|
||||
|
||||
def download(asset, path):
|
||||
path.write_bytes(sums if asset["name"] == "SHA256SUMS" else b"{}")
|
||||
return asset["digest"][7:]
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
report = Path(directory) / "report.json"
|
||||
with mock.patch.object(audit, "find_release", return_value=release), \
|
||||
mock.patch.object(audit, "download_asset", side_effect=download), \
|
||||
mock.patch.object(audit.shutil, "which", return_value="/fake/tool"):
|
||||
with self.assertRaisesRegex(audit.AuditError, "version differs"):
|
||||
audit.audit("v9.8.7", report)
|
||||
self.assertFalse(report.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -21,7 +21,7 @@ class RuntimeCoverage(unittest.TestCase):
|
||||
self.assertIn(case['id'], official)
|
||||
self.assertIn(case['probe'], ('mpris', 'screenshots', 'system-monitor', 'timer-lifecycle',
|
||||
'toggle-settings', 'fileserver', 'localsend', 'network-sentinel',
|
||||
'battery-tracker'))
|
||||
'battery-tracker', 'free-loader'))
|
||||
|
||||
def test_new_shim_without_runtime_case_fails_coverage(self):
|
||||
import frame_decky_store
|
||||
|
||||
@@ -37,14 +37,16 @@ def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7",
|
||||
|
||||
|
||||
class PublishRelease(unittest.TestCase):
|
||||
def run_script(self, releases, *args, tags="v9.8.7", gates=None):
|
||||
def run_script(self, releases, *args, tags="v9.8.7", gates=None,
|
||||
corrupt_manifest_readback=False):
|
||||
d = Path(tempfile.mkdtemp())
|
||||
(d / "releases.json").write_text(json.dumps(releases))
|
||||
(d / "gates.json").write_text(json.dumps(gates or {}))
|
||||
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
|
||||
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
|
||||
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"),
|
||||
FAKEGH_GATES=str(d / "gates.json"))
|
||||
FAKEGH_GATES=str(d / "gates.json"),
|
||||
FAKEGH_CORRUPT_MANIFEST_READBACK="1" if corrupt_manifest_readback else "0")
|
||||
p = subprocess.run(["sh", str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=30)
|
||||
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
|
||||
upload = json.loads((d / "upload.json").read_text()) if (d / "upload.json").exists() else None
|
||||
@@ -60,11 +62,21 @@ class PublishRelease(unittest.TestCase):
|
||||
self.assertEqual(sorted(a["name"] for a in upload["assets"]), sorted(INSTALLERS))
|
||||
self.assertIn(["api", "-X", "DELETE", "repos/saphid/frame-control/releases/assets/7"], log)
|
||||
patch = next(c for c in log if "PATCH" in c)
|
||||
readback = next(c for c in log if any("releases/assets/901" in arg for arg in c))
|
||||
self.assertLess(log.index(readback), log.index(patch))
|
||||
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
|
||||
for f in ("tag_name=v9.8.7", "draft=false", "prerelease=false", "make_latest=true"):
|
||||
self.assertIn(f, patch)
|
||||
self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...` (GraphQL)
|
||||
|
||||
def test_refuses_mismatched_manifest_readback_before_publication(self):
|
||||
p, log, upload = self.run_script([draft()], "v9.8.7",
|
||||
corrupt_manifest_readback=True)
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("downloaded update.json differs", p.stderr)
|
||||
self.assertIsNotNone(upload)
|
||||
self.assertFalse(any("PATCH" in call for call in log))
|
||||
|
||||
def test_an_untagged_draft_may_carry_a_subtitle(self):
|
||||
p, log, upload = self.run_script([draft(name="Frame Control 9.8.7: faster")], "v9.8.7")
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
|
||||
@@ -4703,14 +4703,15 @@
|
||||
"https://github.com/jwhitlow45/free-loader.git"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "The exact archive's duplicate names contain identical bytes and differ only in execute bits; the guarded installer now accepts them, and a private local install/remove transaction passed. The current opaque Chromium host renders Free Games but cannot load deals with backend/network denied. Exact official Python settings and empty local-deals methods passed in a no-network container with a Decky stub. Real Decky, feed refresh, notifications and worn VR remain unverified.",
|
||||
"reason": "The exact archive's byte-identical duplicate ZIP members pass guarded install/remove. Actual Decky 3.3.0 in a private network-isolated container discovers the plugin, reads default settings and empty local deals, and retains a settings change after restart. Live deal-feed refresh, notifications, Steam store integration, real Frame ARM64 service and worn VR remain unverified; no feed request was made.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame pinned install/remove",
|
||||
"local exact-archive transaction",
|
||||
"isolated Python backend"
|
||||
"isolated Python backend",
|
||||
"isolated Linux backend"
|
||||
],
|
||||
"browser": {
|
||||
"id": 63,
|
||||
|
||||
Reference in new issue
Block a user