diff --git a/docs/updates.md b/docs/updates.md index 05410df..8c43982 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -3,7 +3,11 @@ The new ISO includes an independent updater and an independently pinned Ed25519 public key. Discovery uses the public Gitea repository at `https://git.shanaty.ru/Emil_Shanaty/Hydra-OS`; discovery is not a trust source. -An empty releases list is normal and reports no update. +If Gitea has no published runtime release candidate, discovery checks the fixed +public repository path `releases/stable/` on branch `main`. If that path has no +manifest (HTTP 404), it reports no update. Both locations use the same independently +pinned signature key; Git branch contents and repository ownership do not establish +release trust. ```sh hydra update check @@ -82,7 +86,16 @@ python3 dev/release.py \ The three release assets are `manifest.json`, `manifest.sig`, and `runtime.zip`. Publish them under the immutable Gitea tag `1.0.0`, exactly matching the signed -version. Each new release needs a strictly increasing integer sequence; never +version. Alternatively, when the native Gitea release list has no runtime candidates, +publish those same three passive artifacts together in `releases/stable/` on the +repository's `main` branch. The raw HTTPS prefix is fixed: +`https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/`. +The branch serves as a discovery location; the verified manifest supplies its +version and sequence. Keep historical signed artifacts under immutable versions +outside this moving stable pointer. Native Gitea runtime releases take precedence; +a present native candidate, including one already installed, disables this fallback. + +Each new release needs a strictly increasing integer sequence; never reuse a sequence or mutate assets at an existing version. Rollback retains the highest accepted sequence independently of the current installed version. The builder is deterministic for identical source, version, sequence, key and @@ -105,7 +118,15 @@ The maximum manifest is 256 KiB, archive/expanded payload 16 MiB, individual fil per-artifact deadline; redirects remain on the fixed HTTPS origin. Discovery checks up to the latest 20 published release records, skipping draft/prerelease records. Publish runtime releases regularly enough that the current stable release -remains in that window. +remains in that window. Only an otherwise successful discovery with no runtime +candidates activates the repository fallback. API errors, malformed native assets, +tag mismatches and bad signatures remain visible errors and never trigger fallback. +For the repository path, only a missing `manifest.json` (404) means no release; +a missing signature, server error or invalid signature is an error. The ZIP is +fetched only for apply after manifest verification, and its signed hash/size and +all existing download bounds/redirect restrictions still apply. A branch update +between requests may cause a safe verification failure; retry after publication +finishes. For offline/local verification and application: diff --git a/hydra/update_engine.py b/hydra/update_engine.py index e4d22f1..0f65099 100644 --- a/hydra/update_engine.py +++ b/hydra/update_engine.py @@ -15,6 +15,7 @@ import stat import subprocess import sys import tempfile +import urllib.error import urllib.parse import urllib.request import uuid @@ -26,6 +27,7 @@ FILE_LIMIT = 4 * 1024 * 1024 MAX_FILES = 256 ORIGIN = 'https://git.shanaty.ru' RELEASES = ORIGIN + '/api/v1/repos/Emil_Shanaty/Hydra-OS/releases?limit=20' +REPOSITORY_RELEASE = ORIGIN + '/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/' EXACT = {'usr/lib/hydra/bin/hydra', 'etc/motd', 'etc/fastfetch/config.jsonc', 'usr/share/hydra/fastfetch.txt'} @@ -516,6 +518,21 @@ def fetch(url, limit): return b''.join(chunks) +def discover_repository(public_key): + # The mutable branch locates passive bytes; only the pinned signature grants trust. + try: + raw = fetch(REPOSITORY_RELEASE + 'manifest.json', MANIFEST_LIMIT) + except urllib.error.HTTPError as exc: + if exc.code != 404: + raise + exc.close() + return None + # A present manifest with missing/broken companions is an error, never "no release". + signature = fetch(REPOSITORY_RELEASE + 'manifest.sig', 64) + manifest = validate_manifest(raw, signature, public_key) + return manifest, raw, signature, REPOSITORY_RELEASE + 'runtime.zip' + + def discover(public_key): releases = parse_json(fetch(RELEASES, 1024 * 1024)) if not isinstance(releases, list) or len(releases) > 20: @@ -549,7 +566,7 @@ def discover(public_key): raise UpdateError('Release tag does not match signed immutable version') candidates.append((manifest, raw, signature, found['runtime.zip'])) if not candidates: - return None + return discover_repository(public_key) sequences = [item[0]['sequence'] for item in candidates] if len(sequences) != len(set(sequences)): raise UpdateError('Duplicate release sequence') diff --git a/tests/test_update.py b/tests/test_update.py index d8e30a4..4e6aa70 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -250,7 +250,14 @@ CALL def test_empty_releases_and_verified_local_check_do_not_create_state(self): module = engine() from unittest.mock import patch - with patch.object(module, 'fetch', return_value=b'[]'): + from urllib.error import HTTPError + def empty_releases(url, limit): + if url == module.RELEASES: + return b'[]' + if url == 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/manifest.json': + raise HTTPError(url, 404, 'Not Found', {}, None) + raise AssertionError('Unexpected discovery URL: ' + url) + with patch.object(module, 'fetch', side_effect=empty_releases): self.assertIsNone(module.discover(self.pub)) self.package() result = subprocess.run([sys.executable, '-I', str(ENGINE), 'check', '--root', str(self.root), @@ -346,6 +353,7 @@ CALL with patch.object(module, 'fetch', side_effect=fetch): found = module.discover(self.pub) self.assertEqual(found[0]['version'], '1.2.3') + self.assertFalse(any('/raw/branch/' in url for url in requested)) self.assertNotIn(urls['runtime.zip'], requested) release['tag_name'] = 'latest' with self.assertRaisesRegex(ValueError, 'tag'): @@ -462,3 +470,106 @@ CALL self.assertEqual(updater.status(), authoritative) finally: updater.base.chmod(0o700) + + + def test_signed_repository_fallback_applies_and_preserves_replay_protection(self): + self.package() + module = engine() + from unittest.mock import patch + from contextlib import redirect_stdout, redirect_stderr + import io + base = 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/' + requested = [] + def fetch(url, limit): + requested.append(url) + if url == module.RELEASES: + return b'[]' + if url not in {base + name for name in ('manifest.json', 'manifest.sig', 'runtime.zip')}: + raise AssertionError('Unexpected fallback URL: ' + url) + return (self.artifacts / url.rsplit('/', 1)[1]).read_bytes() + with patch.object(module, 'fetch', side_effect=fetch): + found = module.discover(self.pub) + self.assertIsNotNone(found) + self.assertEqual(found[0]['version'], '1.2.3') + self.assertEqual(found[3], base + 'runtime.zip') + self.assertNotIn(base + 'runtime.zip', requested) + with redirect_stdout(io.StringIO()) as output: + code = module.main(['apply', '--root', str(self.root), '--public-key', str(self.pub)]) + self.assertEqual(code, 0) + self.assertTrue(json.loads(output.getvalue())['applied']) + self.assertEqual(self.target.read_bytes(), b'new runtime\n') + with redirect_stderr(io.StringIO()) as error: + code = module.main(['apply', '--root', str(self.root), '--public-key', str(self.pub)]) + self.assertEqual(code, 1) + self.assertIn('replay', error.getvalue()) + self.assertEqual(self.vm.read_bytes(), b'guest disk unchanged') + + def test_repository_fallback_refuses_tamper_missing_signature_and_server_errors(self): + self.package() + module = engine() + from unittest.mock import patch + from urllib.error import HTTPError + base = 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/' + for failure in ('signature', 'missing_signature', 'server_error'): + with self.subTest(failure=failure): + requested = [] + def fetch(url, limit): + requested.append(url) + if url == module.RELEASES: + return b'[]' + if url == base + 'manifest.json': + if failure == 'server_error': + raise HTTPError(url, 503, 'Unavailable', {}, None) + return (self.artifacts / 'manifest.json').read_bytes() + if url == base + 'manifest.sig': + if failure == 'missing_signature': + raise HTTPError(url, 404, 'Not Found', {}, None) + return b'x' * 64 + raise AssertionError('Unsigned archive must not be fetched') + with patch.object(module, 'fetch', side_effect=fetch): + with self.assertRaises((ValueError, HTTPError)): + module.discover(self.pub) + self.assertNotIn(base + 'runtime.zip', requested) + self.assertFalse((self.root / 'var/lib/hydra-update').exists()) + + def test_native_release_errors_do_not_fall_back_to_repository_assets(self): + self.package() + module = engine() + from unittest.mock import patch + from urllib.error import HTTPError + assets = [{'name': name, 'browser_download_url': 'https://git.shanaty.ru/attachments/' + name} + for name in ('manifest.json', 'manifest.sig', 'runtime.zip')] + for failure in ('tag', 'incomplete', 'signature', 'api_unavailable'): + with self.subTest(failure=failure): + def fetch(url, limit): + if '/raw/branch/' in url: + raise AssertionError('API failures must not activate fallback') + if url == module.RELEASES: + if failure == 'api_unavailable': + raise HTTPError(url, 503, 'Unavailable', {}, None) + return json.dumps([{'tag_name': 'wrong' if failure == 'tag' else '1.2.3', + 'assets': assets[:1] if failure == 'incomplete' else assets}]).encode() + if failure == 'signature' and url.endswith('manifest.sig'): + return b'x' * 64 + return (self.artifacts / url.rsplit('/', 1)[1]).read_bytes() + with patch.object(module, 'fetch', side_effect=fetch): + with self.assertRaises((ValueError, HTTPError)): + module.discover(self.pub) + + def test_repository_fallback_downloads_use_existing_size_and_redirect_checks(self): + module = engine() + from unittest.mock import patch + import io + import urllib.request + base = 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/' + def oversized(request, timeout): + if request.full_url == module.RELEASES: + return io.BytesIO(b'[]') + return io.BytesIO(b'x' * (module.MANIFEST_LIMIT + 1)) + with patch('urllib.request.OpenerDirector.open', side_effect=oversized): + with self.assertRaisesRegex(ValueError, 'size'): + module.discover(self.pub) + request = urllib.request.Request(base + 'manifest.json') + for destination in ('http://git.shanaty.ru/manifest.json', 'https://other.example/manifest.json'): + with self.subTest(destination=destination), self.assertRaises(ValueError): + module.SafeRedirect().redirect_request(request, None, 302, '', {}, destination)