Compare commits

...
16 changed files with 757 additions and 14 deletions

No files matched your search

-1
View File
@@ -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"
`;
@@ -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).
@@ -0,0 +1,76 @@
{
"tag": "v0.4.53",
"releaseId": 427006429,
"tagNameAtAudit": "v0.4.53",
"draftAtAudit": true,
"sha256sumsSha256": "813441ac5c121e2c9effd274576aa6db1ff01187f811463a1014f86e0172608c",
"assets": [
{
"name": "Frame-Control-mac-arm64.dmg",
"size": 177629060,
"sha256": "8ae712108aa3fdfb5f40a2f205db65ffd7694914bab2ac0af7a85dafb1c8c31e",
"udifTrailer": "ok"
},
{
"name": "Frame-Control-mac-arm64.zip",
"size": 177452293,
"sha256": "f6f683e43b397d51432a8651d0b54d5d96cfd3e9d75e0fe4a9769cdd10ccf33b",
"zipCrc": "ok",
"members": 1673,
"bundleVersion": "0.4.53"
},
{
"name": "Frame-Control-mac-x64.dmg",
"size": 184700330,
"sha256": "7c25a0d6633aa5312c5e7a1abf8d1abd656e109cc78093a2c5bd29bf790d8e03",
"udifTrailer": "ok"
},
{
"name": "Frame-Control-mac-x64.zip",
"size": 184538726,
"sha256": "fe7c5b46ea718a5e8f5d8cc511813d420fdbdfea4858e4c4c20f92b7aaaed529",
"zipCrc": "ok",
"members": 1669,
"bundleVersion": "0.4.53"
},
{
"name": "Frame-Control-Setup-x64.exe",
"size": 151885488,
"sha256": "9bdbafc00fd170a4e31dbaafc73ce89c952c85a1cbad9198822ea2c473489597",
"peMachine": 332
},
{
"name": "Frame-Control-win-x64.zip",
"size": 198840858,
"sha256": "8360ea2427a84374a2106267d0440dbf5e4d9b22f9ef44e465d31f81c7ebfaff",
"zipCrc": "ok",
"members": 1517
},
{
"name": "Frame-Control-linux-x86_64.AppImage",
"size": 179997230,
"sha256": "7755621360a1aafb6e1bdf2b41838d172a3f6d7f5662d2b5217f6aae08486c2b",
"elfMachine": 62
},
{
"name": "Frame-Control-linux-arm64.AppImage",
"size": 171579370,
"sha256": "985b0a1c3e04af5cc1a41e2e7ad9e8481aac45762b369044bf314972e894dc64",
"elfMachine": 183
},
{
"name": "Frame-Control-linux-amd64.deb",
"size": 146412012,
"sha256": "2adbef7c1bec3e9e40244984598a2e2c9816eb2c21fdb21d6996f9d30f2d0fec",
"debVersion": "0.4.53",
"architecture": "amd64"
},
{
"name": "Frame-Control-linux-arm64.deb",
"size": 133498848,
"sha256": "0625dfdb1959f8eab52ac3f6c4bf909b7de3966c9ca959c391e43630516ff9c7",
"debVersion": "0.4.53",
"architecture": "arm64"
}
]
}
+73
View File
@@ -0,0 +1,73 @@
# v0.4.53 release verification, 28 October 2026
The annotated `v0.4.53` tag dereferences to merged main commit
`e4ce91d6868665e6689a4aa17a27a46bf9abd089`. Its tree matches reviewed
[candidate PR #380](https://github.com/saphid/frame-control/pull/380) head
`a1a376b4ee9464ac9d97426cce21e662e3a9bf1e`; the diff is empty.
## Exact commit gates
- [Main checks](https://github.com/saphid/frame-control/actions/runs/40739079630)
passed all seven jobs on the first attempt, including e2e, iOS, Windows,
macOS, Linux and aggregate checks.
- [Native Decky runtime](https://github.com/saphid/frame-control/actions/runs/40739079822)
passed both published Decky 3.3.0 AMD64 and ARM64 jobs on the first attempt.
- Tagged installer and checksum workflow:
[run 40740373224](https://github.com/saphid/frame-control/actions/runs/40740373224).
All six jobs passed on the first attempt: draft, Linux, Windows, Intel Mac,
Apple Silicon Mac and checksums. No tagged job failed, was cancelled,
skipped or overridden.
Before publication the draft held exactly ten installers and `SHA256SUMS`.
Every installer was independently downloaded by GitHub asset ID; each size
and SHA-256 matched the asset metadata. The downloaded `SHA256SUMS` matched
all ten entries byte for byte and had SHA-256
`813441ac5c121e2c9effd274576aa6db1ff01187f811463a1014f86e0172608c`.
The [sanitized asset audit](v0453-assets-2026-10-28.json) records names,
sizes, hashes and package checks. It verified both Mac ZIP CRCs and bundle
version `0.4.53`, both DMG UDIF trailers, Windows ZIP CRC and PE header,
both AppImage ELF architectures, and both DEB version and architecture fields.
## Real Frame and package checks
The [candidate smoke evidence](v0453-candidate-2026-10-28.md)
records a unique-lock source smoke: 12 passed, one optional x86_64 launch
skipped because Steam Linux Runtime 4 was absent, zero failed. Independent
fresh-lock cleanup found no reserved test titles or owned processes. The
source tree changed only in documentation after the smoke, and the merged
release tree matches the PR head. SteamVR was in standby; nobody was confirmed
wearing the Frame, and no VR input was sent.
The private smoke result had SHA-256
`5a5bc8b1da1ef405235d660a2587c84204afd0816c80f8d9dfd9577dac72d547`;
the fresh-lock cleanup audit had SHA-256
`6ab884b6adffb14c4643e30ee2e5fe247b2ebba7a3ad5424515927cd40146e79`.
The tagged x86_64 AppImage downloaded independently by asset ID had SHA-256
`7755621360a1aafb6e1bdf2b41838d172a3f6d7f5662d2b5217f6aae08486c2b`.
It launched under isolated Xvfb with a temporary profile, telemetry and update
checks disabled; its bundled server returned HTTP 200 with `FrameControl/1`
and stopped after app exit. No physical Mac or Windows launch of these exact
installers was performed.
## Publication
`scripts/publish-release.sh --dry-run v0.4.53` passed exact-main, native
runtime and tagged release jobs, all ten installer hashes, regenerated
`SHA256SUMS`, draft notes and update-manifest checks. Its log had SHA-256
`ddad938f25d4321a8d24b29ff895a20431ec6d1c2c6faf8fd42f939100c97bff`.
The publisher repeated those gates, attached `update.json`, and published
[v0.4.53](https://github.com/saphid/frame-control/releases/tag/v0.4.53) at
**18:14:22 UTC**. Its log had SHA-256
`b54cd7f87c0ba858cc61086fc6293287bdb7555c6a49d3f1c9b8216b39d0dcf3`.
GitHub's latest-release API returned `draft=false`, `prerelease=false`, the
correct tag, ten installers, `SHA256SUMS` and `update.json`. A fresh download
from the public `releases/latest/download/update.json` route reported version
`0.4.53`, the correct release page, all ten names, sizes and hashes matching
the independent downloads, and the explicit 26.5 GB transfer limit. The
public manifest had SHA-256
`b873a3422e60e2fec2ff4a08f0a1fe610b9df18eb94a1a72e259beefb763bb38`.
The exact-version Decky catalog remains **6 works / 54 partial / 45 broken /
11 N/A**. A physical 26.5 GB transfer, wearer-visible VR, physical-controller
use and the optional x86_64 title launch are unverified. The separate hourly
worn check remains enabled.
+1 -1
View File
@@ -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
View File
@@ -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.
+275
View File
@@ -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()
+17 -1
View File
@@ -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 \
+5
View File
@@ -14,6 +14,11 @@
"shim": null,
"probe": "toggle-settings"
},
{
"id": 63,
"shim": null,
"probe": "free-loader"
},
{
"id": 12,
"shim": "music-control-system-dbus-env",
+24
View File
@@ -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
View File
@@ -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)
+172
View File
@@ -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()
+1 -1
View File
@@ -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
+14 -2
View File
@@ -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)
+3 -2
View File
@@ -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,