mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
Add authenticated F-Droid user repositories and management CLI
Verify pinned JAR/CMS signatures, v2 index hashes and APK downloads; support signed v1 fallback and persist TOFU identities. Reuse the catalogue reducer and document repository publishing with offline and live verification evidence. Co-Authored-By: GPT-6 Astra <noreply@openai.com>
This commit is contained in:
1 parent
268afccf05
commit
784a48f218
12 files changed
+903
No files matched your search
@@ -0,0 +1,55 @@
|
||||
# User repositories
|
||||
|
||||
Scope: ui/apk_sources/fdroid.py, fixture tests and docs/apk-repos.md. No headset access.
|
||||
No delegation or independent reviewer launched: task explicitly forbids delegation;
|
||||
parent integrates and reviews. Existing catalogue API stays unchanged.
|
||||
|
||||
Decisions:
|
||||
- Support F-Droid signed v2 and v1, HTTPS only, RSA PKCS#1 CMS/JAR verification.
|
||||
- Pin operator certificate fingerprints. Without a supplied fingerprint, verify
|
||||
the complete signature chain of hashes on first use, then persist that signer.
|
||||
- Reuse frame_catalog._IndexReader and _reduce_index; keep authenticated source
|
||||
cache separate from legacy unauthenticated catalogue cache to avoid laundering trust.
|
||||
- Sources list compatible builds (Android <=30, arm64 or no native code), as
|
||||
existing catalogue reducer does. This is compatibility filtering, not a runtime guarantee.
|
||||
- No Obtainium export import or new unsigned JSON format in this source.
|
||||
|
||||
Research: downloaded F-Droid API/setup docs, Obtainium and SideQuest READMEs,
|
||||
Izzy repo page and entry.jar via HTTPS. Izzy's published fingerprint matched
|
||||
pure-Python CMS validation: 3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A.
|
||||
|
||||
## Final verification
|
||||
|
||||
All commands below ran in /Users/saphid/projects/steam-frame-userrepo on user-repos.
|
||||
|
||||
- `python3 --version`: Python 3.9.6.
|
||||
- `python3 -m unittest discover -s tests -p test_fdroid_sources.py`: initially
|
||||
13 tests, OK, exit 0. Added a cache-corruption test afterward.
|
||||
- Final `python3 -m unittest discover -s tests`: 180 tests in 6.866s, OK,
|
||||
exit 0 (includes 14 source tests and existing catalogue/version tests).
|
||||
- `python3 ui/apk_sources/fdroid.py add 'https://apt.izzysoft.de/fdroid/repo?fingerprint=3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A' --name 'IzzyOnDroid verification'`: exit 0;
|
||||
saved fdroid-user-57e98c13877f14fdea65 with the published pin.
|
||||
- `python3 ui/apk_sources/fdroid.py search fdroid-user-57e98c13877f14fdea65 'tinymusicplayer'`:
|
||||
exit 0; com.martinmimigames.tinymusicplayer, version 1.3 / code 4,
|
||||
GPL-3.0-only, 16,520 bytes.
|
||||
- `python3 ui/apk_sources/fdroid.py download fdroid-user-57e98c13877f14fdea65 com.martinmimigames.tinymusicplayer`:
|
||||
exit 0, verified true. Independent hashlib readback matched
|
||||
d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a.
|
||||
- Direct `_fetch` + `_jar` calls on F-Droid main/archive entry.jar: exit 0;
|
||||
both matched the published 43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab pin.
|
||||
- `.claude/user-repos-proof.json` retains live CLI results and APK readback.
|
||||
Live APK remains in the per-user apk-sources cache; the added source remains
|
||||
in the per-user apk-repos.json, as requested for the real add/search/download run.
|
||||
|
||||
Not verified: headset installation/runtime, native UI/server integration (sibling
|
||||
worker), real v1-only server (offline signed fixture covers fallback), executing
|
||||
the fdroidserver publishing instructions, full F-Droid main/archive index/APK
|
||||
downloads (their live signed entry jars were checked). No independent reviewer
|
||||
was run because this task forbids delegation and assigns review to the parent.
|
||||
|
||||
Known limits / follow-up questions: only one RSA-2048–8192 JAR signer supported;
|
||||
no ECDSA/DSA/PSS or section-only SF signatures; no index timestamp rollback or
|
||||
expiry policy, automated key rotation or cross-process settings-write locking.
|
||||
The settings API serializes threads and publishes atomically. Should a later
|
||||
change add explicit rollback policy and broader JAR algorithms? Parent may
|
||||
choose UI wording for TOFU; this source already returns trust_on_first_use.
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"add": {
|
||||
"id": "fdroid-user-57e98c13877f14fdea65",
|
||||
"kind": "fdroid",
|
||||
"name": "IzzyOnDroid verification",
|
||||
"url": "https://apt.izzysoft.de/fdroid/repo/",
|
||||
"builtin": false,
|
||||
"enabled": true,
|
||||
"trust": "user",
|
||||
"fingerprint": "3bf0d6abfeae2f401707b6d966be743bf0eee49c2561b9ba39073711f628937a",
|
||||
"trust_on_first_use": false
|
||||
},
|
||||
"search": [
|
||||
{
|
||||
"source": "fdroid-user-57e98c13877f14fdea65",
|
||||
"id": "com.martinmimigames.tinymusicplayer",
|
||||
"package": "com.martinmimigames.tinymusicplayer",
|
||||
"name": "Tiny Music Player",
|
||||
"summary": "Android 1.0+ minimal (",
|
||||
"icon": "https://apt.izzysoft.de/fdroid/repo/com.martinmimigames.tinymusicplayer/en-US/icon.png",
|
||||
"page": "https://martinmimigames.github.io/projects/tiny-music-player/index.html",
|
||||
"vr": null,
|
||||
"free": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"downloadable": true,
|
||||
"version": "1.3",
|
||||
"version_code": 4,
|
||||
"min_sdk": 1,
|
||||
"abis": [],
|
||||
"size": 16520,
|
||||
"updated": "2023-02-04"
|
||||
}
|
||||
],
|
||||
"download": {
|
||||
"apk": "/Users/saphid/Library/Caches/Frame Control/apk-sources/d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a.apk",
|
||||
"obb": [],
|
||||
"sha256": "d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a",
|
||||
"verified": true
|
||||
},
|
||||
"independent_readback": {
|
||||
"size": 16520,
|
||||
"sha256": "d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a"
|
||||
},
|
||||
"python": "3.9.6",
|
||||
"suite": "python3 -m unittest discover -s tests: 180 tests, 6.866s, OK, exit 0",
|
||||
"builtins_entry_signatures": {
|
||||
"fdroid": "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab",
|
||||
"fdroid-archive": "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
# APK repositories
|
||||
|
||||
Frame Control supports **F-Droid-format repositories**, including F-Droid,
|
||||
F-Droid archive, IzzyOnDroid and user-provided HTTPS repositories. Repository
|
||||
indexes are authenticated before their apps appear. Search lists builds with
|
||||
Android API ≤30 and arm64-v8a or no native libraries, using the same streaming
|
||||
reducer as the existing catalogue. This does not guarantee an app works in Lepton.
|
||||
|
||||
## Formats considered
|
||||
|
||||
| Format | Users and purpose | Support in this source |
|
||||
|---|---|---|
|
||||
| F-Droid v2 | F-Droid, IzzyOnDroid, self-hosted fdroidserver repositories; consumed by F-Droid clients including Droid-ify and Neo Store | Preferred: signed `entry.jar` authenticates `entry.json`; its SHA-256 authenticates `index-v2.json`, which supplies APK SHA-256 hashes |
|
||||
| F-Droid v1 | Older F-Droid servers and clients | Fallback: verify `index-v1.jar`, then read its signed `index-v1.json` |
|
||||
| Obtainium configurations / exports | Obtainium users share app URLs plus source-specific filters and update settings; exports can contain a list of app configuration objects | Not imported here: configurations describe how to find releases, not one signed repository index |
|
||||
| SideQuest listings / custom feeds | SideQuest's own app discovery and installation service | No interoperable signed custom-repository specification was established from the public project documentation examined; SideQuest needs its own adapter |
|
||||
| GitHub release lists | Developers publish APK assets on release pages; community lists link to projects | Not a repository standard: asset naming, build selection and publisher verification vary; handled separately from this F-Droid source |
|
||||
| Minimal JSON list | A private list could contain package, title, APK URL and SHA-256 | Deliberately not introduced: unsigned hashes downloaded alongside files do not authenticate their publisher; another bespoke signing/update protocol would duplicate F-Droid |
|
||||
|
||||
Research references (checked 2026-09-28):
|
||||
|
||||
- [F-Droid APIs](https://f-droid.org/docs/All_our_APIs/) and
|
||||
[repository setup](https://f-droid.org/docs/Setup_an_F-Droid_App_Repo/).
|
||||
- [F-Droid signing keys](https://f-droid.org/docs/Release_Channels_and_Signing_Keys/)
|
||||
and [IzzyOnDroid's repository page and fingerprint](https://apt.izzysoft.de/fdroid/).
|
||||
- [Droid-ify](https://github.com/Droid-ify/client) and
|
||||
[Neo Store](https://github.com/NeoApplications/Neo-Store).
|
||||
- [Obtainium](https://github.com/ImranR98/Obtainium), its
|
||||
[configuration/deep-link format](https://wiki.obtainium.imranr.dev/deep_links/),
|
||||
and [community app configurations](https://apps.obtainium.imranr.dev/).
|
||||
- [SideQuest's public client](https://github.com/SideQuestVR/SideQuest).
|
||||
The absence of a specification in these materials is not proof that no
|
||||
historical or private custom-feed format exists.
|
||||
|
||||
## Add a repository in Frame Control
|
||||
|
||||
From the Frame Control checkout, use its source-management CLI:
|
||||
|
||||
```sh
|
||||
python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo?fingerprint=YOUR_64_HEX_CERTIFICATE_FINGERPRINT' --name 'My apps'
|
||||
python3 ui/apk_sources/fdroid.py list
|
||||
python3 ui/apk_sources/fdroid.py search SOURCE_ID 'music'
|
||||
python3 ui/apk_sources/fdroid.py download SOURCE_ID org.example.app
|
||||
python3 ui/apk_sources/fdroid.py remove SOURCE_ID
|
||||
```
|
||||
|
||||
Replace `SOURCE_ID` with the `id` printed by `add` or `list`. `--fingerprint`
|
||||
can also supply the pin. `fdroidrepos://example.org/fdroid/repo?fingerprint=…`
|
||||
links are accepted and converted to HTTPS. Conflicting fingerprints are refused.
|
||||
A URL must identify the repository directory, not its website or an index file.
|
||||
|
||||
Adding fetches and validates the complete index **before saving** the source.
|
||||
Without a fingerprint, Frame Control verifies the JAR signature and remembers
|
||||
its signer: trust on first use (TOFU). This establishes continuity with the
|
||||
first server response, not independent publisher identity. Obtain the published
|
||||
fingerprint through a trusted channel when possible. Re-adding an existing URL
|
||||
preserves its pin; changing it requires deliberately removing and re-adding it.
|
||||
|
||||
The API for the search/server integration is in `ui/apk_sources/fdroid.py`:
|
||||
`add_repo(url, fingerprint=None, name=None)`, `remove_repo(source_id)`,
|
||||
`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes
|
||||
`sources`, `search`, `details`, and `download` from the shared source contract.
|
||||
This change supplies the CLI and API; the integrated source-management UI is
|
||||
separate work. Built-in sources can be disabled but cannot be removed.
|
||||
|
||||
Settings and pins live in `frame_host.data_dir('apk-repos.json')`
|
||||
(`~/Library/Application Support/Frame Control/apk-repos.json` on macOS).
|
||||
Authenticated reduced indexes and APKs live under
|
||||
`frame_host.cache_dir('apk-sources')`; indexes refresh after 24 hours.
|
||||
The existing catalogue's unverified index cache is never treated as authenticated.
|
||||
|
||||
## Publish your own repository
|
||||
|
||||
Only publish free APKs you own or have the developer's permission to distribute.
|
||||
Do not publish paid app mirrors or bypass store licences. Check distribution
|
||||
terms before adding someone else's repository; this module does not infer legal
|
||||
permission from a signature or automatically audit a repository's terms.
|
||||
|
||||
Install a current [fdroidserver](https://f-droid.org/docs/Installing_the_Server_and_Repo_Tools/)
|
||||
and its documented Android/Java dependencies on the publishing machine, then:
|
||||
|
||||
```sh
|
||||
mkdir my-fdroid
|
||||
cd my-fdroid
|
||||
fdroid init
|
||||
# Set repo_url in config.yml to https://example.org/fdroid/repo
|
||||
# Also set repo_name and repo_description; keep the generated signing key safe.
|
||||
cp /path/to/your-free-app.apk repo/
|
||||
fdroid update --create-metadata
|
||||
# Review the generated metadata (name, summary, licence, source and website).
|
||||
fdroid update
|
||||
```
|
||||
|
||||
Serve the generated **repo directory** at that HTTPS URL, including APKs,
|
||||
icons, `entry.jar`, `index-v2.json` and `index-v1.jar`. Do not publish the
|
||||
private signing keystore or configuration passwords. Configure fdroidserver's
|
||||
`serverwebroot` and run `fdroid deploy` for managed publication, or copy the
|
||||
public directory with your existing deployment tool. Publish the SHA-256
|
||||
repository certificate fingerprint displayed by fdroidserver in a link such as
|
||||
`https://example.org/fdroid/repo?fingerprint=…`.
|
||||
|
||||
Keep the repository signing key backed up: changing it breaks existing pins.
|
||||
For updates, add the new APK, edit metadata as needed, run `fdroid update` and
|
||||
publish again. Test the published URL with Frame Control's `add`, `search` and
|
||||
`download` commands. The above publisher setup is documented from fdroidserver;
|
||||
it was not executed as part of this implementation.
|
||||
|
||||
## Verification and limits
|
||||
|
||||
The stdlib verifier supports one RSA PKCS#1 v1.5 JAR/CMS signer with a key of
|
||||
2048–8192 bits; SHA-256/384/512 and legacy SHA-1 digest encodings are
|
||||
recognized. It checks the signer certificate pin, the signature over `.SF`,
|
||||
the whole-manifest digest, and the manifest's digest of the JSON member.
|
||||
ECDSA, DSA, RSA-PSS, multiple signers and section-only `.SF` manifests are
|
||||
rejected. Certificates are pinned identities, not validated as Web PKI chains.
|
||||
HTTPS certificates are separately checked by Python's normal TLS validation.
|
||||
|
||||
v1 fallback occurs only when `entry.jar` returns HTTP 404 or 410. Signature,
|
||||
fingerprint, index hash, TLS and server errors never trigger an unsigned
|
||||
fallback. APKs are cached by SHA-256 and checked again before reuse. Here,
|
||||
`verified: true` means the bytes match the signed repository's APK hash; it
|
||||
is not an independent APK publisher-signature or runtime compatibility verdict.
|
||||
There is no repository timestamp rollback/expiry policy or automated signing-key
|
||||
rotation yet. An old correctly signed index can still validate.
|
||||
|
||||
Offline fixtures exercise v2, v1, TOFU, pin changes, disabled sources, cache
|
||||
reuse, URL rejection and corruption of every signature/hash layer. On the Mac,
|
||||
the real IzzyOnDroid repository was added with its published pin, searched for
|
||||
Tiny Music Player, and its 16,520-byte APK downloaded with SHA-256
|
||||
`d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a`.
|
||||
No headset connection or installation was performed.
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
# F-Droid verification fixtures
|
||||
|
||||
`entry.jar` and `index-v1.jar` are synthetic RSA-2048/SHA-256 signed JARs,
|
||||
including CMS signed attributes. `fingerprint.txt` identifies their throwaway
|
||||
certificate. Their JSON describes org.example.app; `example.apk` is deliberately
|
||||
plain test data, not an installable app. The v2 index includes incompatible
|
||||
Android-31 and x86-only versions to exercise the shared reducer.
|
||||
|
||||
`izzy-entry.jar` was recorded from
|
||||
https://apt.izzysoft.de/fdroid/repo/entry.jar on 2026-09-28. Its certificate
|
||||
fingerprint matches the operator's published fingerprint:
|
||||
3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A.
|
||||
It exercises an independent production JAR/CMS encoder without network access.
|
||||
The index it references is not needed by this signature-only fixture test.
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+1
@@ -0,0 +1 @@
|
||||
Fixture APK payload, deliberately not installable.
|
||||
+1
@@ -0,0 +1 @@
|
||||
0e87b227cd414d7093fb150fda81f1754900f3abc810e6785d8e0767c6eb798a
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"repo": {"name": {"en-US": "Fixture"}}, "packages": {"org.example.app": {"metadata": {"name": {"en-US": "Example"}, "summary": {"en-US": "Offline fixture"}, "license": "MIT"}, "versions": {"1": {"manifest": {"versionName": "1", "versionCode": 1, "usesSdk": {"minSdkVersion": 21}, "nativecode": []}, "file": {"name": "/example1.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "2": {"manifest": {"versionName": "2", "versionCode": 2, "usesSdk": {"minSdkVersion": 30}, "nativecode": ["arm64-v8a"]}, "file": {"name": "/example2.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "3": {"manifest": {"versionName": "3", "versionCode": 3, "usesSdk": {"minSdkVersion": 31}, "nativecode": []}, "file": {"name": "/example3.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "4": {"manifest": {"versionName": "4", "versionCode": 4, "usesSdk": {"minSdkVersion": 21}, "nativecode": ["x86_64"]}, "file": {"name": "/example4.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}}}}}
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,176 @@
|
||||
"""Offline authenticated repository fixtures; no tests contact a server."""
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import urllib.error
|
||||
import zipfile
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
from apk_sources import SourceError, fdroid
|
||||
|
||||
FIXTURES = Path(__file__).parent / 'fixtures' / 'fdroid'
|
||||
PIN = (FIXTURES / 'fingerprint.txt').read_text().strip()
|
||||
URL = 'https://example.org/repo/'
|
||||
|
||||
|
||||
class Repositories(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.root = Path(tmp.name)
|
||||
for name, value in [('data_dir', lambda *p: self.root.joinpath('data', *p)),
|
||||
('cache_dir', lambda *p: self.root.joinpath('cache', *p))]:
|
||||
mock = patch.object(fdroid.frame_host, name, value)
|
||||
mock.start()
|
||||
self.addCleanup(mock.stop)
|
||||
net = patch.object(fdroid.urllib.request, 'build_opener', side_effect=AssertionError('network forbidden'))
|
||||
net.start()
|
||||
self.addCleanup(net.stop)
|
||||
mock = patch.object(fdroid, '_fetch', side_effect=self.fetch)
|
||||
self.fetch_mock = mock.start()
|
||||
self.addCleanup(mock.stop)
|
||||
self.v1 = False
|
||||
self.corrupt = None
|
||||
|
||||
def fetch(self, url, path, maximum):
|
||||
name = url.rsplit('/', 1)[-1]
|
||||
if self.v1 and name == 'entry.jar':
|
||||
raise urllib.error.HTTPError(url, 404, 'missing', None, None)
|
||||
payload = (FIXTURES / ('example.apk' if name.endswith('.apk') else name)).read_bytes()
|
||||
if name == self.corrupt:
|
||||
payload += b'tampered'
|
||||
Path(path).write_bytes(payload)
|
||||
|
||||
def add(self):
|
||||
return fdroid.add_repo(URL, PIN)
|
||||
|
||||
def test_add_search_details_download_cache(self):
|
||||
source = self.add()
|
||||
self.assertEqual(source['fingerprint'], PIN)
|
||||
self.assertFalse(source['trust_on_first_use'])
|
||||
result = fdroid.search(source, 'example offline')
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertNotIn('versions', result[0])
|
||||
self.assertEqual(result[0]['version_code'], 2)
|
||||
self.assertEqual([v['version_code'] for v in fdroid.details(source, 'org.example.app')['versions']], [2, 1])
|
||||
downloaded = fdroid.download(source, 'org.example.app', 1)
|
||||
self.assertTrue(downloaded['verified'])
|
||||
self.assertEqual(Path(downloaded['apk']).read_bytes(), (FIXTURES / 'example.apk').read_bytes())
|
||||
count = self.fetch_mock.call_count
|
||||
fdroid.download(source, 'org.example.app', 1)
|
||||
self.assertEqual(self.fetch_mock.call_count, count)
|
||||
|
||||
def test_wrong_pin_is_not_saved(self):
|
||||
with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'):
|
||||
fdroid.add_repo(URL, '0' * 64)
|
||||
self.assertEqual(fdroid.user_repos(), [])
|
||||
|
||||
def test_tampered_index_is_not_saved(self):
|
||||
self.corrupt = 'index-v2.json'
|
||||
with self.assertRaisesRegex(SourceError, 'SHA-256'):
|
||||
self.add()
|
||||
self.assertEqual(fdroid.user_repos(), [])
|
||||
|
||||
def test_tampered_apk_is_not_cached(self):
|
||||
source = self.add()
|
||||
self.corrupt = 'example2.apk'
|
||||
with self.assertRaisesRegex(SourceError, 'APK SHA-256'):
|
||||
fdroid.download(source, 'org.example.app')
|
||||
self.assertEqual(list(self.root.rglob('*.apk')), [])
|
||||
self.assertEqual(list(self.root.rglob('*.part')), [])
|
||||
|
||||
def test_v1_fallback(self):
|
||||
self.v1 = True
|
||||
source = self.add()
|
||||
self.assertEqual(fdroid.search(source, 'Example')[0]['version_code'], 1)
|
||||
self.assertTrue(fdroid.download(source, 'org.example.app')['verified'])
|
||||
|
||||
def test_bad_v2_never_downgrades(self):
|
||||
self.corrupt = 'index-v2.json'
|
||||
with self.assertRaises(SourceError):
|
||||
self.add()
|
||||
self.assertFalse(any(c.args[0].endswith('index-v1.jar') for c in self.fetch_mock.call_args_list))
|
||||
|
||||
def test_transient_error_never_downgrades(self):
|
||||
self.fetch_mock.side_effect = urllib.error.HTTPError(URL, 503, 'unavailable', None, None)
|
||||
with self.assertRaises(SourceError):
|
||||
self.add()
|
||||
self.assertEqual(self.fetch_mock.call_count, 1)
|
||||
|
||||
def test_tofu_preserves_pin_and_settings(self):
|
||||
source = fdroid.add_repo('fdroidrepos://example.org/repo')
|
||||
self.assertTrue(source['trust_on_first_use'])
|
||||
self.assertEqual(source['fingerprint'], PIN)
|
||||
fdroid.add_repo(URL)
|
||||
self.assertEqual(len(fdroid.user_repos()), 1)
|
||||
with self.assertRaisesRegex(SourceError, 'different pinned'):
|
||||
fdroid.add_repo(URL, '0' * 64)
|
||||
fdroid.set_enabled(source['id'], False)
|
||||
self.assertEqual(fdroid.search(fdroid.user_repos()[0], ''), [])
|
||||
with self.assertRaisesRegex(SourceError, 'disabled'):
|
||||
fdroid.download(fdroid.user_repos()[0], 'org.example.app')
|
||||
fdroid.set_enabled('fdroid', False)
|
||||
self.assertFalse(fdroid.sources()[0]['enabled'])
|
||||
fdroid.remove_repo(source['id'])
|
||||
self.assertEqual(fdroid.user_repos(), [])
|
||||
with self.assertRaises(SourceError):
|
||||
fdroid.remove_repo('fdroid')
|
||||
|
||||
def test_urls(self):
|
||||
self.assertEqual(fdroid._url(URL + '?fingerprint=' + PIN.upper()), (URL, PIN))
|
||||
for url in ['http://example.org/repo', 'fdroidrepo://example.org', 'https://u:p@example.org', URL+'?other=x']:
|
||||
with self.subTest(url=url), self.assertRaises(SourceError):
|
||||
fdroid._url(url)
|
||||
with self.assertRaisesRegex(SourceError, 'conflicting'):
|
||||
fdroid._url(URL + '?fingerprint=' + PIN, '0' * 64)
|
||||
for name in ['../x.apk', '%2e%2e/x.apk', 'https://evil.org/a.apk', '//evil.org/../x', 'x?token=y', 'x\\y']:
|
||||
with self.subTest(name=name), self.assertRaises(SourceError):
|
||||
fdroid._child(URL, name)
|
||||
|
||||
def test_recorded_real_signature(self):
|
||||
content, fingerprint = fdroid._jar(FIXTURES / 'izzy-entry.jar', 'entry.json', fdroid.IZZY_PIN)
|
||||
self.assertEqual(fingerprint, fdroid.IZZY_PIN)
|
||||
self.assertIn('index', json.loads(content))
|
||||
|
||||
def test_tampering_each_signature_layer(self):
|
||||
for member in ['entry.json', 'META-INF/MANIFEST.MF', 'META-INF/TEST.SF', 'META-INF/TEST.RSA']:
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(FIXTURES / 'entry.jar') as src, zipfile.ZipFile(stream, 'w') as dst:
|
||||
for item in src.infolist():
|
||||
data = src.read(item.filename)
|
||||
if item.filename == member:
|
||||
data = data[:-1] + bytes([data[-1] ^ 1])
|
||||
dst.writestr(item.filename, data)
|
||||
with self.subTest(member=member), self.assertRaises(SourceError):
|
||||
fdroid._jar(io.BytesIO(stream.getvalue()), 'entry.json', PIN)
|
||||
|
||||
def test_duplicate_jar_member_rejected(self):
|
||||
stream = io.BytesIO((FIXTURES / 'entry.jar').read_bytes())
|
||||
import warnings
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore', UserWarning)
|
||||
with zipfile.ZipFile(stream, 'a') as z:
|
||||
z.writestr('entry.json', '{}')
|
||||
stream.seek(0)
|
||||
with self.assertRaisesRegex(SourceError, 'duplicate'):
|
||||
fdroid._jar(stream, 'entry.json', PIN)
|
||||
|
||||
def test_corrupt_cache_refetches_verified_index(self):
|
||||
source = self.add()
|
||||
fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json').write_text('{')
|
||||
self.assertEqual(len(fdroid.search(source, 'example')), 1)
|
||||
self.assertEqual(self.fetch_mock.call_count, 4)
|
||||
|
||||
def test_cached_index_does_not_cross_pins(self):
|
||||
source = self.add()
|
||||
source['fingerprint'] = '0' * 64
|
||||
with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'):
|
||||
fdroid.search(source, '')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,474 @@
|
||||
"""Signed F-Droid repositories. CLI: add|remove|list|search|download."""
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
if __package__ in (None, ''):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from apk_sources import SourceError
|
||||
import frame_host
|
||||
from frame_apk_sign import _der_parts, _cert_key, der
|
||||
from frame_catalog import _IndexReader, _reduce_index, _sha256
|
||||
|
||||
KIND = 'fdroid'
|
||||
_LOCK = threading.RLock()
|
||||
# Published by the repository operators; a user repository without a pin uses TOFU.
|
||||
FDROID_PIN = '43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab'
|
||||
IZZY_PIN = '3bf0d6abfeae2f401707b6d966be743bf0eee49c2561b9ba39073711f628937a'
|
||||
_DIGESTS = {
|
||||
'608648016503040201': ('sha256', '3031300d060960864801650304020105000420'),
|
||||
'608648016503040202': ('sha384', '3041300d060960864801650304020205000430'),
|
||||
'608648016503040203': ('sha512', '3051300d060960864801650304020305000440'),
|
||||
'2b0e03021a': ('sha1', '3021300906052b0e03021a05000414'),
|
||||
}
|
||||
|
||||
|
||||
def _fingerprint(value):
|
||||
value = re.sub(r'[:\s]', '', value or '').lower()
|
||||
if not re.fullmatch('[0-9a-f]{64}', value):
|
||||
raise SourceError('fingerprint must be a SHA-256 certificate fingerprint (64 hex digits)')
|
||||
return value
|
||||
|
||||
|
||||
def _url(url, fingerprint=None):
|
||||
if url.startswith('fdroidrepos://'):
|
||||
url = 'https://' + url[len('fdroidrepos://'):]
|
||||
p = urllib.parse.urlsplit(url)
|
||||
if p.scheme != 'https' or not p.hostname or p.username or p.password or p.fragment:
|
||||
raise SourceError('repository URL must use HTTPS without credentials or a fragment')
|
||||
params = urllib.parse.parse_qs(p.query)
|
||||
pins = params.pop('fingerprint', [])
|
||||
if params or len(pins) > 1:
|
||||
raise SourceError('only one fingerprint query parameter is supported')
|
||||
pin = _fingerprint(fingerprint) if fingerprint else None
|
||||
if pins:
|
||||
linked = _fingerprint(pins[0])
|
||||
if pin and pin != linked:
|
||||
raise SourceError('conflicting fingerprints')
|
||||
pin = linked
|
||||
return urllib.parse.urlunsplit(('https', p.netloc.lower(), p.path.rstrip('/') + '/', '', '')), pin
|
||||
|
||||
|
||||
def _child(base, name):
|
||||
name = str(name).lstrip('/')
|
||||
decoded = urllib.parse.unquote(name)
|
||||
if not name or '\\' in decoded or any(x in ('.', '..') for x in decoded.split('/')):
|
||||
raise SourceError('unsafe repository file name')
|
||||
url = urllib.parse.urljoin(base, name)
|
||||
if not url.startswith(base) or urllib.parse.urlsplit(url).query or urllib.parse.urlsplit(url).fragment:
|
||||
raise SourceError('repository file is outside its repository')
|
||||
return url
|
||||
|
||||
|
||||
class _HTTPSRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
if urllib.parse.urlsplit(newurl).scheme != 'https':
|
||||
raise SourceError('refusing non-HTTPS redirect')
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
def _fetch(url, path, maximum):
|
||||
request = urllib.request.Request(url, headers={'User-Agent': 'FrameControl/1.0'})
|
||||
with urllib.request.build_opener(_HTTPSRedirect()).open(request, timeout=60) as r, open(path, 'wb') as f:
|
||||
total = 0
|
||||
while True:
|
||||
chunk = r.read(1 << 20)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > maximum:
|
||||
raise SourceError('repository file exceeds size limit')
|
||||
f.write(chunk)
|
||||
|
||||
|
||||
def _children(item):
|
||||
return _der_parts(item[1])
|
||||
|
||||
|
||||
def _cms(data, content):
|
||||
outer = _der_parts(data)
|
||||
if len(outer) != 1:
|
||||
raise ValueError('invalid CMS wrapper')
|
||||
wrapper = _children(outer[0])
|
||||
if wrapper[0][1].hex() != '2a864886f70d010702':
|
||||
raise ValueError('not CMS SignedData')
|
||||
fields = _children(_children(wrapper[1])[0])
|
||||
certs = next(_children(f) for f in fields[3:] if f[0] == 0xa0)
|
||||
signers = _children(fields[-1])
|
||||
if len(signers) != 1:
|
||||
raise ValueError('exactly one repository signer required')
|
||||
signer = _children(signers[0])
|
||||
sid = _children(signer[1])
|
||||
matching = []
|
||||
for cert in certs:
|
||||
tbs = _children(_children(cert)[0])
|
||||
offset = 1 if tbs[0][0] == 0xa0 else 0
|
||||
if tbs[offset][1] == sid[1][1] and tbs[offset + 2][2] == sid[0][2]:
|
||||
matching.append(cert[2])
|
||||
if len(matching) != 1:
|
||||
raise ValueError('missing or ambiguous signer certificate')
|
||||
cert = matching[0]
|
||||
digest, prefix = _DIGESTS[_children(signer[2])[0][1].hex()]
|
||||
at, signed = 3, content
|
||||
if signer[at][0] == 0xa0:
|
||||
attrs = {}
|
||||
for attr in _children(signer[at]):
|
||||
pair = _children(attr)
|
||||
oid = pair[0][1].hex()
|
||||
if oid in attrs:
|
||||
raise ValueError('duplicate CMS attribute')
|
||||
attrs[oid] = _children(pair[1])
|
||||
if attrs['2a864886f70d010904'][0][1] != hashlib.new(digest, content).digest():
|
||||
raise ValueError('CMS content digest mismatch')
|
||||
if attrs['2a864886f70d010903'][0][1].hex() != '2a864886f70d010701':
|
||||
raise ValueError('unexpected CMS content type')
|
||||
signed = der(0x31, signer[at][1])
|
||||
at += 1
|
||||
algorithm = _children(signer[at])[0][1].hex()
|
||||
allowed = {'sha1': '2a864886f70d010105', 'sha256': '2a864886f70d01010b',
|
||||
'sha384': '2a864886f70d01010c', 'sha512': '2a864886f70d01010d'}
|
||||
if algorithm not in ('2a864886f70d010101', allowed[digest]):
|
||||
raise ValueError('unsupported repository signature algorithm (RSA PKCS#1 required)')
|
||||
n, e, _ = _cert_key(cert)
|
||||
sig = signer[at + 1][1]
|
||||
size = (n.bit_length() + 7) // 8
|
||||
if not 256 <= size <= 1024 or n % 2 != 1 or not 3 <= e <= 0xffffffff or e % 2 != 1 or len(sig) != size or int.from_bytes(sig, 'big') >= n:
|
||||
raise ValueError('invalid RSA signature/key size')
|
||||
value = bytes.fromhex(prefix) + hashlib.new(digest, signed).digest()
|
||||
expected = b'\0\1' + b'\xff' * (size - len(value) - 3) + b'\0' + value
|
||||
if pow(int.from_bytes(sig, 'big'), e, n).to_bytes(size, 'big') != expected:
|
||||
raise ValueError('repository RSA signature mismatch')
|
||||
return hashlib.sha256(cert).hexdigest()
|
||||
|
||||
|
||||
def _sections(data):
|
||||
sections = []
|
||||
for block in re.split(b'\r?\n\r?\n', data):
|
||||
if not block:
|
||||
continue
|
||||
attrs = {}
|
||||
for line in re.sub(b'\r?\n ', b'', block).splitlines():
|
||||
key, value = line.decode('utf-8').split(': ', 1)
|
||||
key = key.lower()
|
||||
if key in attrs:
|
||||
raise ValueError('duplicate manifest attribute')
|
||||
attrs[key] = value
|
||||
sections.append(attrs)
|
||||
return sections
|
||||
|
||||
|
||||
def _digest_check(attrs, suffix, content):
|
||||
for label, digest in (('sha-512', 'sha512'), ('sha-384', 'sha384'), ('sha-256', 'sha256'), ('sha1', 'sha1'), ('sha-1', 'sha1')):
|
||||
if label + suffix in attrs:
|
||||
if base64.b64decode(attrs[label + suffix], validate=True) != hashlib.new(digest, content).digest():
|
||||
raise ValueError('JAR digest mismatch')
|
||||
return
|
||||
raise ValueError('missing supported JAR digest')
|
||||
|
||||
|
||||
def _jar(path, member, pin):
|
||||
try:
|
||||
with zipfile.ZipFile(path) as z:
|
||||
names = z.namelist()
|
||||
if len(names) > 64 or len(names) != len(set(names)) or any(
|
||||
i.file_size > (1024 * 1024 if i.filename.upper().startswith('META-INF/') else 256 * 1024 * 1024)
|
||||
for i in z.infolist()):
|
||||
raise ValueError('duplicate or oversized JAR member')
|
||||
blocks = [n for n in names if n.upper().startswith('META-INF/') and n.upper().endswith('.RSA')]
|
||||
if len(blocks) != 1:
|
||||
raise ValueError('exactly one RSA JAR signer required')
|
||||
sf = z.read(blocks[0][:-4] + '.SF')
|
||||
fingerprint = _cms(z.read(blocks[0]), sf)
|
||||
if pin and fingerprint != pin:
|
||||
raise ValueError('repository fingerprint mismatch')
|
||||
manifest = z.read('META-INF/MANIFEST.MF')
|
||||
_digest_check(_sections(sf)[0], '-digest-manifest', manifest)
|
||||
entries = [s for s in _sections(manifest)[1:] if s.get('name') == member]
|
||||
if len(entries) != 1:
|
||||
raise ValueError('index is not uniquely signed')
|
||||
content = z.read(member)
|
||||
_digest_check(entries[0], '-digest', content)
|
||||
return content, fingerprint
|
||||
except (ValueError, KeyError, IndexError, StopIteration, RuntimeError, NotImplementedError, zipfile.BadZipFile) as e:
|
||||
raise SourceError('invalid signed repository: ' + str(e)) from e
|
||||
|
||||
|
||||
def _storage():
|
||||
return frame_host.data_dir('apk-repos.json')
|
||||
|
||||
|
||||
def _read():
|
||||
try:
|
||||
settings = json.loads(_storage().read_text())
|
||||
if not isinstance(settings, dict) or not isinstance(settings.get('repos'), list) or not isinstance(settings.get('enabled'), dict):
|
||||
raise ValueError('invalid settings structure')
|
||||
return settings
|
||||
except FileNotFoundError:
|
||||
return {'repos': [], 'enabled': {}}
|
||||
except (OSError, ValueError) as e:
|
||||
raise SourceError('cannot read repository settings: ' + str(e)) from e
|
||||
|
||||
|
||||
def _write(path, value):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix='.part')
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
json.dump(value, f, separators=(',', ':'))
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
|
||||
|
||||
def user_repos():
|
||||
with _LOCK:
|
||||
return _read()['repos']
|
||||
|
||||
|
||||
def sources():
|
||||
builtins = [('fdroid', 'F-Droid', 'https://f-droid.org/repo/', FDROID_PIN),
|
||||
('fdroid-archive', 'F-Droid archive', 'https://f-droid.org/archive/', FDROID_PIN),
|
||||
('izzyondroid', 'IzzyOnDroid', 'https://apt.izzysoft.de/fdroid/repo/', IZZY_PIN)]
|
||||
settings = _read()
|
||||
return [dict(id=i, kind=KIND, name=n, url=u, fingerprint=p, builtin=True,
|
||||
enabled=settings['enabled'].get(i, True), trust='community')
|
||||
for i, n, u, p in builtins] + settings['repos']
|
||||
|
||||
|
||||
def _text(value):
|
||||
if isinstance(value, dict):
|
||||
return value.get('en-US') or value.get('en') or next(iter(value.values()), '')
|
||||
return value or ''
|
||||
|
||||
|
||||
def _reduce(path, source):
|
||||
compatible = _reduce_index(path)
|
||||
result = {}
|
||||
with open(path, encoding='utf-8') as f:
|
||||
reader = _IndexReader(f)
|
||||
for key in reader.members():
|
||||
if key != 'packages':
|
||||
reader.value()
|
||||
continue
|
||||
for pkg in reader.members():
|
||||
item = reader.value()
|
||||
if pkg not in compatible:
|
||||
continue
|
||||
meta = item.get('metadata', {})
|
||||
files = {v['file'].get('name'): v for v in item.get('versions', {}).values()
|
||||
if isinstance(v, dict) and isinstance(v.get('file'), dict)}
|
||||
versions = []
|
||||
for v in compatible[pkg]:
|
||||
original = files[v['name']]
|
||||
versions.append(dict(v, size=original['file'].get('size'), updated=_date(original.get('added'))))
|
||||
versions.sort(key=lambda v: (v['version_code'], v['abis'] == ['arm64-v8a']), reverse=True)
|
||||
latest = versions[0]
|
||||
icon = _text(meta.get('icon'))
|
||||
result[pkg] = dict(source=source['id'], id=pkg, package=pkg,
|
||||
name=_text(meta.get('name')) or pkg, summary=_text(meta.get('summary')),
|
||||
icon=_child(source['url'], icon['name']) if isinstance(icon, dict) and icon.get('name') else None,
|
||||
page=meta.get('webSite') or source['url'], vr=None, free=True,
|
||||
license=meta.get('license'), downloadable=bool(latest.get('sha256')),
|
||||
versions=versions, **{k: latest[k] for k in ('version', 'version_code', 'min_sdk', 'abis', 'size', 'updated')})
|
||||
return result
|
||||
|
||||
|
||||
def _date(value):
|
||||
return time.strftime('%Y-%m-%d', time.gmtime(value / 1000)) if isinstance(value, (int, float)) else None
|
||||
|
||||
|
||||
def _v1(content, path):
|
||||
index = json.loads(content)
|
||||
apps = {a['packageName']: a for a in index['apps']}
|
||||
packages = {}
|
||||
for pkg, builds in index['packages'].items():
|
||||
app = apps.get(pkg, {})
|
||||
localized = app.get('localized', {})
|
||||
en = localized.get('en-US') or next(iter(localized.values()), {})
|
||||
meta = {k: en.get(k) or app.get(k) for k in ('name', 'summary', 'license', 'webSite')}
|
||||
versions = {}
|
||||
for i, v in enumerate(builds):
|
||||
versions[str(i)] = {'manifest': {'versionName': v.get('versionName'), 'versionCode': v['versionCode'],
|
||||
'usesSdk': {'minSdkVersion': v.get('minSdkVersion', 1)}, 'nativecode': v.get('nativecode', [])},
|
||||
'file': {'name': v['apkName'], 'sha256': v.get('hash') if v.get('hashType') == 'sha256' else None,
|
||||
'size': v.get('size')}, 'added': v.get('added')}
|
||||
packages[pkg] = {'metadata': meta, 'versions': versions}
|
||||
path.write_text(json.dumps({'packages': packages}))
|
||||
|
||||
|
||||
def _load(source, force=False):
|
||||
if not re.fullmatch(r'[a-z0-9-]+', source['id']):
|
||||
raise SourceError('invalid source id')
|
||||
_url(source['url'], source.get('fingerprint'))
|
||||
cache = frame_host.cache_dir('apk-sources', source['id'] + '.json')
|
||||
with _LOCK:
|
||||
if not force and cache.exists() and time.time() - cache.stat().st_mtime < 86400:
|
||||
try:
|
||||
saved = json.loads(cache.read_text())
|
||||
if saved.get('fingerprint') == source.get('fingerprint') and saved.get('url') == source['url']:
|
||||
return saved['apps'], saved['fingerprint']
|
||||
except (OSError, ValueError, KeyError, AttributeError):
|
||||
pass
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(dir=str(cache.parent)) as tmp:
|
||||
jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json'
|
||||
try:
|
||||
_fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code not in (404, 410):
|
||||
raise
|
||||
_fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024)
|
||||
content, pin = _jar(jar, 'index-v1.json', source.get('fingerprint'))
|
||||
_v1(content, raw)
|
||||
else:
|
||||
content, pin = _jar(jar, 'entry.json', source.get('fingerprint'))
|
||||
entry = json.loads(content)['index']
|
||||
_fetch(_child(source['url'], entry['name']), raw, 512 * 1024 * 1024)
|
||||
if _sha256(raw) != entry['sha256'] or (entry.get('size') is not None and raw.stat().st_size != entry['size']):
|
||||
raise SourceError('index SHA-256 or size mismatch')
|
||||
apps = _reduce(raw, source)
|
||||
_write(cache, {'url': source['url'], 'fingerprint': pin, 'apps': apps})
|
||||
return apps, pin
|
||||
except SourceError:
|
||||
raise
|
||||
except (OSError, ValueError, KeyError, TypeError, IndexError) as e:
|
||||
raise SourceError('cannot load repository: ' + str(e)) from e
|
||||
|
||||
|
||||
def add_repo(url, fingerprint=None, name=None):
|
||||
url, pin = _url(url, fingerprint)
|
||||
with _LOCK:
|
||||
settings = _read()
|
||||
existing = next((s for s in settings['repos'] if s['url'] == url), None)
|
||||
if existing:
|
||||
if pin and pin != existing['fingerprint']:
|
||||
raise SourceError('repository already has a different pinned fingerprint; remove it first')
|
||||
pin = existing['fingerprint']
|
||||
source = dict(id='fdroid-user-' + hashlib.sha256(url.encode()).hexdigest()[:20], kind=KIND,
|
||||
name=name or (existing or {}).get('name') or urllib.parse.urlsplit(url).hostname,
|
||||
url=url, builtin=False, enabled=True, trust='user', fingerprint=pin)
|
||||
_, source['fingerprint'] = _load(source, force=True)
|
||||
source['trust_on_first_use'] = existing.get('trust_on_first_use', False) if existing else pin is None
|
||||
settings['repos'] = [s for s in settings['repos'] if s['id'] != source['id']] + [source]
|
||||
_write(_storage(), settings)
|
||||
return source
|
||||
|
||||
|
||||
def remove_repo(source_id):
|
||||
with _LOCK:
|
||||
settings = _read()
|
||||
if not any(s['id'] == source_id for s in settings['repos']):
|
||||
raise SourceError('unknown user repository')
|
||||
settings['repos'] = [s for s in settings['repos'] if s['id'] != source_id]
|
||||
_write(_storage(), settings)
|
||||
|
||||
|
||||
def set_enabled(source_id, enabled):
|
||||
if not isinstance(enabled, bool):
|
||||
raise SourceError('enabled must be a boolean')
|
||||
with _LOCK:
|
||||
settings = _read()
|
||||
source = next((s for s in sources() if s['id'] == source_id), None)
|
||||
if not source:
|
||||
raise SourceError('unknown repository')
|
||||
if source['builtin']:
|
||||
settings['enabled'][source_id] = enabled
|
||||
else:
|
||||
for s in settings['repos']:
|
||||
if s['id'] == source_id:
|
||||
s['enabled'] = enabled
|
||||
_write(_storage(), settings)
|
||||
|
||||
|
||||
def search(source, query, limit=50):
|
||||
if not source.get('enabled', True):
|
||||
return []
|
||||
apps, _ = _load(source)
|
||||
words = query.casefold().split()
|
||||
found = [a for a in apps.values() if all(w in (a['id'] + ' ' + a['name'] + ' ' + a['summary']).casefold() for w in words)]
|
||||
found.sort(key=lambda a: (a['id'].casefold() != query.casefold(), a['name'].casefold()))
|
||||
return [{k: v for k, v in a.items() if k != 'versions'} for a in found[:max(0, limit)]]
|
||||
|
||||
|
||||
def details(source, entry_id):
|
||||
if not source.get('enabled', True):
|
||||
raise SourceError('repository is disabled')
|
||||
apps, _ = _load(source)
|
||||
if entry_id not in apps:
|
||||
raise SourceError('app has no Lepton-compatible version in this repository')
|
||||
return apps[entry_id]
|
||||
|
||||
|
||||
def download(source, entry_id, version_code=None):
|
||||
entry = details(source, entry_id)
|
||||
version = next((v for v in entry['versions'] if version_code is None or str(v['version_code']) == str(version_code)), None)
|
||||
if not version or not re.fullmatch('[0-9a-f]{64}', version.get('sha256') or ''):
|
||||
raise SourceError('version is missing or has no SHA-256 digest')
|
||||
sha = version['sha256']
|
||||
path = frame_host.cache_dir('apk-sources', sha + '.apk')
|
||||
try:
|
||||
if not path.exists() or _sha256(path) != sha:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix='.part')
|
||||
os.close(fd)
|
||||
try:
|
||||
_fetch(_child(source['url'], version['name']), tmp, 4 * 1024 ** 3)
|
||||
if _sha256(tmp) != sha:
|
||||
raise SourceError('APK SHA-256 mismatch; download discarded')
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
return {'apk': str(path), 'obb': [], 'sha256': sha, 'verified': True}
|
||||
except OSError as e:
|
||||
raise SourceError('cannot download APK: ' + str(e)) from e
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest='command', required=True)
|
||||
add = sub.add_parser('add')
|
||||
add.add_argument('url')
|
||||
add.add_argument('--fingerprint')
|
||||
add.add_argument('--name')
|
||||
sub.add_parser('list')
|
||||
remove = sub.add_parser('remove')
|
||||
remove.add_argument('source')
|
||||
for command in ('search', 'download'):
|
||||
p = sub.add_parser(command)
|
||||
p.add_argument('source')
|
||||
p.add_argument('query' if command == 'search' else 'package')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.command == 'add':
|
||||
result = add_repo(args.url, args.fingerprint, args.name)
|
||||
elif args.command == 'list':
|
||||
result = sources()
|
||||
elif args.command == 'remove':
|
||||
result = remove_repo(args.source)
|
||||
else:
|
||||
source = next((s for s in sources() if s['id'] == args.source), None)
|
||||
if not source:
|
||||
raise SourceError('unknown repository id; use list')
|
||||
result = search(source, args.query) if args.command == 'search' else download(source, args.package)
|
||||
print(json.dumps(result, indent=2))
|
||||
except SourceError as e:
|
||||
parser.exit(1, 'error: ' + str(e) + '\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user