diff --git a/.github/workflows/publish-opensuse.yml b/.github/workflows/publish-opensuse.yml
new file mode 100644
index 0000000..0cd1cc2
--- /dev/null
+++ b/.github/workflows/publish-opensuse.yml
@@ -0,0 +1,84 @@
+name: Publish openSUSE Repository
+
+on:
+ workflow_call:
+ inputs:
+ tag:
+ type: string
+ required: true
+ operation:
+ type: string
+ default: publish
+ workflow_dispatch:
+ inputs:
+ operation:
+ description: Publish a stable release, refresh expiry, or roll back to a retained revision
+ type: choice
+ options: [publish, refresh, rollback]
+ default: publish
+ tag:
+ description: Existing stable release tag for publish
+ type: string
+ revision:
+ description: Retained repository revision SHA-256 for rollback
+ type: string
+ schedule:
+ - cron: "17 6 * * 1"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: opensuse-repository-production
+ cancel-in-progress: false
+
+jobs:
+ publish:
+ if: >-
+ ${{
+ vars.OPENSUSE_REPOSITORY_ENABLED == 'true' &&
+ (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) ||
+ (github.workflow == 'Release' && startsWith(github.ref, 'refs/tags/v')))
+ }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 40
+ environment: packages-production
+ container:
+ image: opensuse/tumbleweed@sha256:b6821dbfad5422b663e0eeae8241a30ef2976e1e9ae4a2f827641c0eecf7e4fd
+ steps:
+ - name: Install repository and workflow tools
+ run: >-
+ zypper --non-interactive --gpg-auto-import-keys refresh &&
+ zypper --non-interactive install
+ createrepo_c gh git gpg2 nodejs24 openssh openssl python3 rpm-build
+
+ - name: Checkout publisher
+ uses: actions/checkout@v7.0.0
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Build, publish, and verify repository
+ env:
+ GH_TOKEN: ${{ github.token }}
+ OPERATION: ${{ inputs.operation || 'refresh' }}
+ RELEASE_TAG: ${{ inputs.tag }}
+ ROLLBACK_REVISION: ${{ inputs.revision }}
+ OPENSUSE_REPOSITORY_URL: ${{ vars.OPENSUSE_REPOSITORY_URL }}
+ OPENSUSE_REPOSITORY_HOST: ${{ vars.OPENSUSE_REPOSITORY_HOST }}
+ OPENSUSE_REPOSITORY_ROOT: ${{ vars.OPENSUSE_REPOSITORY_ROOT }}
+ OPENSUSE_SSH_PORT: ${{ vars.OPENSUSE_SSH_PORT || '22' }}
+ OPENSUSE_SIGNING_FINGERPRINT: ${{ vars.OPENSUSE_SIGNING_FINGERPRINT }}
+ OPENSUSE_SSH_PRIVATE_KEY: ${{ secrets.OPENSUSE_SSH_PRIVATE_KEY }}
+ OPENSUSE_SSH_KNOWN_HOSTS: ${{ secrets.OPENSUSE_SSH_KNOWN_HOSTS }}
+ OPENSUSE_SIGNING_KEY: ${{ secrets.OPENSUSE_SIGNING_KEY }}
+ OPENSUSE_SIGNING_PASSPHRASE: ${{ secrets.OPENSUSE_SIGNING_PASSPHRASE }}
+ run: bash scripts/publish-opensuse-workflow.sh
+
+ - name: Upload public verification and activation record
+ uses: actions/upload-artifact@v7.0.1
+ with:
+ name: loopwire-opensuse-publication-${{ github.run_id }}
+ path: dist/opensuse-publication
+ if-no-files-found: error
+ retention-days: 90
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b6d8443..11e05fe 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -523,3 +523,13 @@ jobs:
tag: ${{ needs.publish-release.outputs.tag }}
operation: publish
secrets: inherit
+
+ publish-opensuse:
+ name: Publish signed openSUSE channel
+ needs: publish-release
+ if: ${{ vars.OPENSUSE_REPOSITORY_ENABLED == 'true' && !contains(needs.publish-release.outputs.tag, '-') }}
+ uses: ./.github/workflows/publish-opensuse.yml
+ with:
+ tag: ${{ needs.publish-release.outputs.tag }}
+ operation: publish
+ secrets: inherit
diff --git a/.github/workflows/workflow-checks.yml b/.github/workflows/workflow-checks.yml
index 5d34f1b..24c4d29 100644
--- a/.github/workflows/workflow-checks.yml
+++ b/.github/workflows/workflow-checks.yml
@@ -9,6 +9,7 @@ on:
- "scripts/test-ci-workflow-paths.rb"
- "scripts/test-apt-workflow.rb"
- "scripts/test-fedora-workflow.rb"
+ - "scripts/test-opensuse-workflow.rb"
- "scripts/*native-package-proof-snapshot.mjs"
- "scripts/verify-github-workflows.sh"
- "scripts/verify-requirements.sh"
@@ -25,6 +26,7 @@ on:
- "scripts/test-ci-workflow-paths.rb"
- "scripts/test-apt-workflow.rb"
- "scripts/test-fedora-workflow.rb"
+ - "scripts/test-opensuse-workflow.rb"
- "scripts/*native-package-proof-snapshot.mjs"
- "scripts/verify-github-workflows.sh"
- "scripts/verify-requirements.sh"
diff --git a/.planning/quick/260905-nrs-signed-opensuse/260905-nrs-PLAN.md b/.planning/quick/260905-nrs-signed-opensuse/260905-nrs-PLAN.md
new file mode 100644
index 0000000..693d77c
--- /dev/null
+++ b/.planning/quick/260905-nrs-signed-opensuse/260905-nrs-PLAN.md
@@ -0,0 +1,58 @@
+---
+status: implementing
+issue: 37
+depends_on: 46
+---
+
+# Signed openSUSE repository development
+
+Goal: complete every development task in #37 and open a dedicated PR containing `resolves #37`. This branch is an
+intentional stack on #36/PR #46 because the openSUSE channel reuses the exact-release provenance, RPM signing,
+content-addressed metadata retention, protected publication, and KVM proof foundations introduced there. Production
+provider ownership, credentials, signing identity, GitHub environment configuration, and first public activation
+remain the separately listed human operational tasks.
+
+## Decisions
+
+- Choose a project-owned repository over OBS. It publishes the exact authenticated project release RPM, preserves the
+ release/source/build identity already recorded by Loopwire, and gives the project explicit promotion, retention,
+ rollback, and recovery semantics. OBS would rebuild and sign a distinct output, requiring provider project/build
+ identities and provider-specific proof that cannot be produced without the human operational setup.
+- Initial scope is openSUSE Tumbleweed x86_64 only. Leap and other architectures remain unsupported until they receive
+ their own package and clean-guest validation.
+- Reuse the shared RPM repository protocol only where libzypp/Zypper behavior proves it compatible. Keep the target,
+ client bootstrap, workflow, public activation record, docs, and guest proof openSUSE-specific.
+- Require authenticated repository metadata and embedded RPM signatures. The bootstrap path must preserve Zypper
+ checks and may not use `--no-gpg-checks` or `--allow-unsigned-rpm`.
+- Retain immutable signed RPMs and content-addressed metadata indefinitely in v1. Publication must serialize writers,
+ require revision CAS, reject immutable collisions, recover interruption, and expose only complete revisions or a
+ verification failure during promotion.
+- The checked-in openSUSE channel remains pending. Existing signed direct-download and automatic-installer paths stay
+ visible until a maintainer completes and reviews the production public proof record.
+
+## Work lanes
+
+1. Repository protocol: extend exact-release authentication, RPM/repository signing, metadata manifests, rollback, and
+ tamper tests for the openSUSE Tumbleweed target, grounded in real Zypper/libzypp behavior.
+2. Publication: extend local/SSH publication, atomic promotion, CAS/locks/recovery, immutable retention, and public
+ HTTP proof for the openSUSE namespace.
+3. Guest proof: run an isolated clean Tumbleweed KVM lifecycle using the actual public v0.1.0 openSUSE RPM, plus a
+ strictly synthetic upgrade, and verify raw provenance, installed bytes, providers, backend JSON, and GUI linkage.
+4. Product/docs: add the pending/verified homepage gate, tested bootstrap, provider decision, rolling-snapshot policy,
+ support matrix, release/operator guidance, navigation, and unreleased notes.
+5. Root integration: protected workflow, config contracts, focused and full gates, browser fixtures, final review,
+ issue checklist update, and PR delivery.
+
+## Required verification
+
+- Real Zypper accepts correct signed metadata and package content and rejects wrong, unsigned, or tampered content.
+ Version, architecture, target, vendor/origin, downgrade, and repository removal behavior are independently verified.
+- Publication proves writer exclusion, CAS, ordering, idempotence, interruption recovery, permissions, immutable
+ retention, rollback, actual SSH transport, and public cache behavior without production credentials.
+- A clean checksum-pinned Tumbleweed KVM guest performs repository install, reinstall, fixture upgrade, explicit
+ rollback/downgrade, removal, and repository removal against final committed development code. Evidence records the
+ snapshot and binds source/distributed hashes, signer, origin/vendor, versions, installed bytes, providers, and GUI.
+- Documentation defines a repeatable later-snapshot compatibility run and requires disabling activation/escalating a
+ failed snapshot until the package or compatibility declaration is repaired and the full lifecycle reruns.
+- Pending and verified-fixture browser states, workflow/action syntax, docs, focused tests, and full project gates pass.
+ Public production remains disabled and no human task is reported complete without its real evidence.
diff --git a/.planning/quick/260905-nrs-signed-opensuse/260905-nrs-SUMMARY.md b/.planning/quick/260905-nrs-signed-opensuse/260905-nrs-SUMMARY.md
new file mode 100644
index 0000000..3a5ca2e
--- /dev/null
+++ b/.planning/quick/260905-nrs-signed-opensuse/260905-nrs-SUMMARY.md
@@ -0,0 +1,88 @@
+---
+status: complete
+issue: 37
+depends_on: 46
+---
+
+# Signed openSUSE repository development
+
+Issue: https://github.com/sandwichfarm/loopwire/issues/37
+
+## Result and stack
+
+The openSUSE Tumbleweed x86_64 development work is complete. This branch intentionally stacks on #36/PR #46 so the
+channel can reuse the reviewed exact-release provenance, RPM signing, SSH/POSIX publication, protected environment,
+and KVM foundations while retaining an independent target, namespace, state, workflow, bootstrap, activation record,
+documentation, and guest proof. The checked-in channel remains `pending`; the five Human operational tasks still own
+production hosting, signing custody, GitHub configuration, first public publication, and activation.
+
+## Development checklist evidence
+
+1. **Provider evaluation:** the maintainer runbook compares OBS and project-owned delivery across output identity,
+ signing, promotion/recovery, and rolling compatibility. Project-owned delivery was selected because it consumes the
+ exact authenticated GitHub Release RPM and supports the existing independently testable publication protocol. OBS
+ would produce a provider build requiring provisioned project/build identities and separate provider proof.
+2. **Package path:** the generator accepts only the Tumbleweed x86_64 `loopwire-VERSION-1.x86_64.rpm`, authenticates
+ the OpenSSL-signed SHA256SUMS plus release-assets manifest, and binds the exact RPM and x86_64 archive to the stable
+ tag and public release commit. The public source RPM is never mutated; only a staged copy is repository-signed.
+3. **Signing:** an isolated OpenPGP identity signs the staged RPM and `repodata/repomd.xml`. The manifest and signed
+ metadata retain separate source/distributed hashes and the public source revision. Zypper is configured with
+ `gpgcheck=1`, `repo_gpgcheck=1`, and `pkg_gpgcheck=1`; wrong, unsigned, and tampered inputs fail closed.
+4. **Publication/rollback:** openSUSE uses `/opensuse/tumbleweed/x86_64` publicly and isolated private locks, CAS,
+ journals, and snapshots under `channels/opensuse-tumbleweed-x86_64`. Immutable objects are retained indefinitely in
+ v1. Signature-first/atomic-metadata promotion, interruption recovery, retry idempotence, and freshly signed rollback
+ were tested with real Zypper, actual SSH, and live Nginx cache behavior.
+5. **Protected automation:** Publish openSUSE Repository uses a checksum-pinned Tumbleweed toolchain and supports stable
+ release publication, weekly refresh, and retained-revision rollback. `OPENSUSE_REPOSITORY_ENABLED=true` plus the
+ `packages-production` environment gate writes. The job waits for existing release gates, verifies public release
+ inputs, publishes over pinned SSH, verifies served HTTPS bytes, and emits a reviewable activation record.
+6. **Bootstrap:** the repeat-safe helper accepts Tumbleweed x86_64 only, downloads the fingerprint-addressed public key
+ over HTTPS, verifies one complete primary fingerprint before writes, and atomically installs only its managed key
+ and `.repo` file. Dry-run has no network or writes. Removal preserves packages, accepted RPM-database trust, and
+ unrelated repositories. Docs cover the interactive key prompt, rotation, vendor protection, priority, and cleanup.
+7. **Regression tests:** real Zypper accepts the signed repository and rejects wrong/malformed keys, missing/changed
+ signatures, changed metadata, and unsigned/tampered/wrong-key RPMs. Generator and publisher tests cover target,
+ NEVRA/version/architecture, release provenance, downgrade/repack rejection, deterministic retention, encrypted
+ keys, unsafe paths, locks, CAS, every interruption point, permissions, SSH transport, HTTP caching, and rollback.
+8. **Tumbleweed lifecycle/compatibility:** a checksum-pinned clean Tumbleweed `20260829` KVM guest used the actual
+ public v0.1.0 openSUSE RPM and authenticated release manifest. It installed and reinstalled the baseline, upgraded
+ only to the declared synthetic `+zypperfixture1`, rolled back to the public baseline, removed the package, isolated
+ trust database, and repository. Evidence binds source/distributed hashes, release commit, signature, transaction
+ origin, active candidate, native installed view, vendor, `/usr` bytes, providers, backend JSON, GUI linkage, and a
+ real X11 window at every installed stage. Later snapshot runs consume the same explicit target manifest during run
+ and verification; a failure blocks activation until repaired or compatibility is narrowed and the lifecycle reruns.
+9. **Docs/UI:** homepage, install guide, support matrix, release guide, user/operator guides, packaging docs, navigation,
+ and unreleased notes are updated. Pending preserves the authenticated direct-RPM/automatic installer fallback;
+ verified-fixture proof switches only openSUSE to `sudo zypper install loopwire` with separate setup guidance. The
+ repository is identified as third-party and never described as default-distribution or publicly active.
+
+## Verification
+
+- Final pre-guest `pnpm check` passed requirements/docs, automation, workflow contracts, runtime/install/package gates,
+ types, 295 workspace tests, 22 Rust tests, production builds, static-site checks, and APT/Fedora/openSUSE suites.
+- `pnpm verify:opensuse-repository` passed in the pinned Tumbleweed image: 7 generator, 27 publisher, 7 bootstrap,
+ 6 public HTTPS, 3 workflow preflight, workflow contract, 39 raw proof-verifier, and 5 channel-gate cases. The publisher
+ exercised actual SSH and real Zypper failure/recovery around mixed metadata.
+- Fedora regression verification passed: 13 generator and 27 publisher cases plus bootstrap, public, workflow, proof,
+ and UI gates, including real DNF behavior.
+- The clean KVM lifecycle at `08a18e4484627e36233ab068891bc468e9613f84` produced 159 evidence files and passed an
+ independent replay. The guest is snapshot `20260829`; its image SHA-256 is
+ `e80d2d1f9cfb328c79a5031d6a30f9744ec83824f6ba44c41ce831ff829a5dad`. Public baseline source SHA-256 is
+ `f6bc10589e6308fdc405faa104835cd6bcc486c4bc3fba95b5808b94267f1d05`, bound to public release commit
+ `dfdecf30d681c553a906ceebb13c859bb1b46ef3`.
+- Pending and verified-fixture Chromium suites passed all platform, command, keyboard/copy, no-JavaScript, responsive,
+ motion/reduced-motion/visibility, openSUSE guide, and setup-link assertions. Correct selected-tab screenshots passed
+ visual verdict at 96/100; the checked-in channel and final build were restored to pending.
+- Actionlint, ShellCheck, Python/Node/Ruby/Bash syntax, docs/workflow contracts, and whitespace passed. Comprehensive
+ review findings for read-only syntax, snapshot-manifest propagation, and workflow path coverage were fixed and the
+ re-review approved the result.
+
+## Boundaries
+
+The issue's five Human operational tasks remain unchecked. No production account, OBS project, host, TLS/DNS,
+OpenPGP identity, SSH credential, GitHub environment value, repository publication, or website activation was created
+or changed. Fixture keys, CA, SSH server, synthetic upgrade, and isolated RPM trust database are disposable.
+
+Zypper authenticates repository metadata and RPMs but does not enforce Loopwire's custom signed verification deadline;
+HTTPS/origin control, weekly refresh, and monitoring remain operational requirements. Power-loss and network
+filesystems were not exercised; production requires local POSIX lock/fsync/atomic-rename behavior.
diff --git a/apps/docs/docs/.vitepress/config.ts b/apps/docs/docs/.vitepress/config.ts
index 4ca04f9..c042e8a 100644
--- a/apps/docs/docs/.vitepress/config.ts
+++ b/apps/docs/docs/.vitepress/config.ts
@@ -45,6 +45,7 @@ export default defineConfig({
{ text: "Install", link: "/guide/install" },
{ text: "APT Repository", link: "/guide/apt-repository" },
{ text: "Fedora Repository", link: "/guide/fedora-repository" },
+ { text: "openSUSE Repository", link: "/guide/opensuse-repository" },
{ text: "Basic Usage", link: "/guide/basic-usage" },
{ text: "Configurations", link: "/guide/configurations" },
{ text: "Audio Backends", link: "/guide/backends" },
@@ -64,6 +65,7 @@ export default defineConfig({
{ text: "Release", link: "/developer/release" },
{ text: "APT Repository Operations", link: "/developer/apt-repository" },
{ text: "Fedora Repository Operations", link: "/developer/fedora-repository" },
+ { text: "openSUSE Repository Operations", link: "/developer/opensuse-repository" },
{ text: "Release Notes", link: "/developer/release-notes" }
]
},
diff --git a/apps/docs/docs/developer/opensuse-repository.md b/apps/docs/docs/developer/opensuse-repository.md
new file mode 100644
index 0000000..8298992
--- /dev/null
+++ b/apps/docs/docs/developer/opensuse-repository.md
@@ -0,0 +1,228 @@
+# Signed openSUSE repository operations
+
+This runbook is for maintainers publishing and recovering Loopwire's third-party Tumbleweed channel. Development
+tooling and isolated guest evidence do not establish production availability. Provisioning the origin, signing key,
+protected environment, first public publication, and reviewed activation remain human operations. The checked-in
+channel stays `pending` until that public proof exists; signed direct downloads remain available.
+
+## Scope and provider decision
+
+The only initial target is **openSUSE Tumbleweed x86_64**. Its canonical repository URL ends in
+`/opensuse/tumbleweed/x86_64`. The command-line target selector is `opensuse-tumbleweed-x86_64`; the public channel
+record uses `target: "opensuse-tumbleweed"`. Leap and ARM64 need separate package and clean-guest validation.
+
+The project selected project-owned hosting after comparing it with Open Build Service (OBS):
+
+OBS separates build, signing, and publishing services; an integration would need to track that provider workflow and
+its output identity. See the [official OBS architecture](https://openbuildservice.org/help/manuals/obs-user-guide/cha-obs-architecture).
+
+| Requirement | OBS | Project-owned repository |
+| --- | --- | --- |
+| Release identity | A provider build would need its own project, build, and exact-output provenance. | Authenticate the existing GitHub Release RPM and sign a staged copy; retain source and distributed hashes. |
+| Signing and ownership | Provider project and signing operations require provisioned ownership and their own trust procedure. | A dedicated OpenPGP identity signs RPMs and metadata under the protected project environment. |
+| Promotion and recovery | Requires a provider-specific publication/proof implementation. | Reuse verified SSH/POSIX publication, revision checks, immutable retention, and recovery journals. |
+| Rolling compatibility | Provider build success alone would not prove the installed Loopwire lifecycle. | Require the recorded Tumbleweed snapshot and complete clean-guest install/upgrade/rollback proof. |
+
+This is a project decision about the implemented proof and release flow, not a claim that OBS cannot host Loopwire.
+No OBS project is provisioned or advertised by this change. Project-owned hosting preserves the authenticated release
+payload and known publication contract at the cost of operating HTTPS, SSH access, signing recovery, monitoring, and
+storage. Revisit OBS only with a separate provider integration and equivalent exact-output/client evidence.
+
+## Trust, layout, and provisioning
+
+The generator first verifies `SHA256SUMS.sig` using the existing OpenSSL release key, then the exact openSUSE RPM's
+checksum and target identity. It signs only a staged copy with the repository OpenPGP key; the GitHub Release input
+stays unchanged. The manifest records both hashes because adding an embedded RPM signature changes the distributed
+bytes. Zypper must verify both signed `repodata/repomd.xml` and the RPM's embedded signature.
+
+The public tree contains fingerprint-named keys, immutable RPMs, checksum-named metadata, `repomd.xml`, its detached
+signature, and the repository manifest. Provision an HTTPS origin with a valid public certificate, Python 3, and
+restricted SSH access. Keep staging and public files on one POSIX filesystem. Serve **`ROOT/public` only**. For this
+target, public objects live below `ROOT/public/opensuse/tumbleweed/x86_64`, while private state and snapshots live below
+`ROOT/channels/opensuse-tumbleweed-x86_64`. Fedora retains its separate namespace and state.
+
+Do not expose locks, journals, snapshots, private keys, or SSH credentials to HTTP. Back up private state and snapshots.
+Existing directory permissions must allow the HTTP service to traverse the public tree and its ancestors; the
+publisher preserves pre-provisioned directory modes. The existing website upload surface does not establish the
+required atomic rename and revision-check contract, so package publication uses SSH/POSIX hosting separately.
+
+Mutable `repomd.xml`, its signature, the current manifest, and missing-object responses require `no-store` or mandatory
+revalidation. RPMs, fingerprint-named public keys, and checksum-named metadata can have a one-year immutable cache
+policy. Never cache a missing mutable file through a publication. Use the repository Nginx example as the starting
+point and verify actual public headers.
+
+Create a dedicated OpenPGP identity offline. Record the complete uppercase 40-character fingerprint, expiry,
+custodian, backup, and revocation-certificate location. Keep recovery material outside CI and never send the private
+key to the origin. Configure the protected GitHub environment **`packages-production`**:
+
+| Kind | Name | Purpose |
+| --- | --- | --- |
+| Repository variable | `OPENSUSE_REPOSITORY_ENABLED` | Enables protected publication only after provisioning. |
+| Variable | `OPENSUSE_REPOSITORY_URL` | Canonical HTTPS target URL ending in `/opensuse/tumbleweed/x86_64`. |
+| Variable | `OPENSUSE_REPOSITORY_HOST` | Restricted SSH `USER@HOST`. |
+| Variable | `OPENSUSE_REPOSITORY_ROOT` | Absolute private origin root, whose `public` child is served. |
+| Variable | `OPENSUSE_SIGNING_FINGERPRINT` | Complete uppercase OpenPGP fingerprint. |
+| Optional variable | `OPENSUSE_SSH_PORT` | SSH port, default 22. |
+| Secret | `OPENSUSE_SSH_PRIVATE_KEY` | Restricted publishing identity. |
+| Secret | `OPENSUSE_SSH_KNOWN_HOSTS` | Host-key pins obtained through a trusted channel. |
+| Secret | `OPENSUSE_SIGNING_KEY` | ASCII-armored private signing export. |
+| Optional secret | `OPENSUSE_SIGNING_PASSPHRASE` | Passphrase for that export. |
+
+Restrict environment branches/tags to reviewed inputs and apply the environment's human approval policy. Keep the
+enable variable false until every input is ready. Enabling writes does not activate the website. Monitor publication
+and refresh failures, TLS/signing expiry, project verification deadlines, origin drift, and storage growth.
+
+## Build, verify, and publish
+
+Use a reviewed checkout and authenticated published stable release files. Set `RPM_FPR` to the full OpenPGP
+fingerprint and `RPM_GNUPG_HOME` to its protected local GnuPG home. These are operator staging commands:
+
+The input directory must contain the openSUSE RPM, `loopwire-linux-x86_64.tar.gz`, `release-assets.json`, `SHA256SUMS`,
+and `SHA256SUMS.sig`. The signed asset manifest binds the release tag, full source commit, archive, and exact native
+RPM hashes. Any other release assets present must also match that authenticated inventory. A checksum-only RPM
+directory is insufficient for this target's source/build provenance checks.
+
+```bash
+python3 scripts/rpm-repository.py build --target opensuse-tumbleweed-x86_64 \
+ --release-dir dist/release --version 0.1.0 --output dist/opensuse-repository \
+ --signing-key "$RPM_FPR" --gnupg-home "$RPM_GNUPG_HOME" \
+ --release-public-key packaging/release-signing-public.pem
+python3 scripts/rpm-repository.py verify --target opensuse-tumbleweed-x86_64 \
+ --repository dist/opensuse-repository --public-key rpm-public.asc --fingerprint "$RPM_FPR"
+```
+
+Local tools include Python 3, RPM/rpmsign, `createrepo_c`, GnuPG, and OpenSSL. Actual Zypper acceptance is a separate
+matching-distribution test. `--previous DIR` retains older objects/packages. `--passphrase-file FILE` supplies a private
+passphrase file. `--date EPOCH` controls reproducible fixtures; production uses current time. A 30-day signed project
+verification deadline is enforced by Loopwire's verifier and publisher. Zypper does not enforce that custom tag, and
+`autorefresh=1` is not an anti-replay guarantee. Refresh metadata before the deadline and monitor the origin.
+
+Use **Publish openSUSE Repository** for protected production publication, refresh, and rollback. Tagged release
+integration runs only after GitHub Release publication and when `OPENSUSE_REPOSITORY_ENABLED=true`. An openSUSE failure
+does not retract the existing GitHub Release or mutate the Fedora/APT channel. Repair and retry the target job.
+
+The publisher's `fetch`, `publish`, and `recover` operations require the explicit openSUSE selector. In these examples,
+`RPM_ROOT` and `RPM_HOST` are the provisioned origin root/host, and `RPM_REVISION` is the verified current revision:
+
+```bash
+python3 scripts/publish-rpm-repository.py fetch --target opensuse-tumbleweed-x86_64 \
+ --root "$RPM_ROOT" --output dist/opensuse-previous \
+ --public-key rpm-public.asc --fingerprint "$RPM_FPR" \
+ --ssh "$RPM_HOST" --identity-file rpm-ssh-key --known-hosts rpm-known-hosts
+python3 scripts/publish-rpm-repository.py publish --target opensuse-tumbleweed-x86_64 \
+ --repository dist/opensuse-repository --root "$RPM_ROOT" \
+ --public-key rpm-public.asc --fingerprint "$RPM_FPR" --expected-revision "$RPM_REVISION" \
+ --ssh "$RPM_HOST" --identity-file rpm-ssh-key --known-hosts rpm-known-hosts --dry-run
+```
+
+Initial publication skips fetch and uses an empty expected revision. For later publication, build using the fetched
+snapshot as `--previous`, review the dry-run output, and repeat without `--dry-run`. Add `--ssh-port PORT` if needed.
+A lock serializes this target's writers; compare-and-swap rejects a stale expected revision. Refetch and rebuild after
+a conflict instead of overriding it. Immutable collisions are rejected.
+
+Immutable objects are installed first. Promotion writes the new detached metadata signature and atomically replaces
+`repomd.xml`. The pair is not a single filesystem operation: during the bounded signature/metadata mismatch a client
+must fail verification and retry, never treat incomplete state as successful installation. Existing complete package
+objects remain available. Require successful refresh, correct source/candidate selection, and a real authenticated
+package install when checking Zypper behavior.
+
+## Recovery, retention, and rollback
+
+An interrupted promotion retains an exact recovery journal. Inspect and resume it with the same target, key, and SSH
+arguments using `recover --dry-run`, then repeat without `--dry-run`. Fetch is blocked while recovery is pending.
+For a journal whose project deadline has passed, `recover --allow-expired` is an explicit operator exception that
+finishes only that authentic transition and requires immediate fetch, fresh signing, and publication. Ordinary
+publication never accepts an expired candidate. Fetch can authenticate expired snapshots at their signed creation
+time for recovery; that does not prove current client usability.
+
+Version 1 retains immutable RPMs, key files, checksum-named metadata, and private snapshots indefinitely. There is no
+automatic garbage collection. Any deletion policy needs a separate design for cached clients, manifest references,
+rollback availability, and incident evidence. Do not edit generated state to bypass retention or revision checks.
+
+Fetch a known-good snapshot with `fetch --revision SHA` and re-sign its package set with fresh metadata:
+
+```bash
+python3 scripts/rpm-repository.py rollback --target opensuse-tumbleweed-x86_64 \
+ --repository dist/opensuse-known-good --output dist/opensuse-rollback \
+ --signing-key "$RPM_FPR" --gnupg-home "$RPM_GNUPG_HOME"
+```
+
+Verify and publish against the current revision, then repeat public verification. Existing newer installations need
+an explicit client downgrade: `sudo zypper install --oldpackage --no-allow-vendor-change --no-allow-arch-change --from loopwire 'loopwire=VERSION-RELEASE'`.
+The [user guide](../guide/opensuse-repository.md#earlier-versions) covers exact-version selection and dependency errors.
+
+## Key rotation and revocation
+
+Generate a successor key offline and announce its full fingerprint through trusted project channels. The conservative
+rotation path uses a fresh origin root/HTTPS prefix and authenticated release inputs signed by the successor. Test
+clean setup, existing-client migration, install/reinstall/upgrade/downgrade, and removal before production. Clients
+rerun the inspected bootstrap with the reviewed new URL and key; package updates do not silently grant a new signer
+trust. Keep the old prefix during the announced migration period only while its key remains trustworthy.
+
+Fingerprint-named public key objects are immutable. Extending expiry or changing subkeys changes bytes and cannot
+overwrite the same URL; use the successor-key procedure. Old snapshots still require their original trust anchor.
+The bootstrap removes only its managed files; keys accepted by RPM/libzypp require separate exact-identity review.
+
+For compromise, disable publication/refresh, remove the private CI export, publish the revocation and incident notice,
+and return the public channel record to pending. Clients must remove the old source and bootstrap an independently
+verified replacement; a revocation certificate alone does not repair cached keyrings. Preserve incident evidence and
+recover only from authenticated release inputs or independently known-good snapshots.
+
+## Rolling snapshot policy
+
+Record Tumbleweed's `/etc/os-release` `VERSION_ID`, the checksum-pinned guest image identity, package version, Zypper
+and RPM versions, signer, repository origin/vendor, and source/distributed hashes in each proof. One passing snapshot
+does not imply future rolling compatibility. Before widening a compatibility claim, repeat the complete lifecycle on
+the intended newer snapshot using the same authenticated release inputs and a fresh isolated VM.
+
+Use a reviewed target-manifest override through `LOOPWIRE_NATIVE_VM_TARGETS` for the newer official image and its
+verified SHA-256. Use a distinct `LOOPWIRE_OPENSUSE_VM_ROOT` so old evidence is preserved. Run both `run-opensuse-repo`
+and `verify-opensuse-repo` against the new snapshot; retain the manifest and logs with the evidence.
+
+**A failed newer-snapshot run blocks activation.** If a public channel is already advertised, set its record to pending,
+deploy the fallback instructions, and pause publication while investigating. File the exact failure and snapshot,
+repair the package or explicitly narrow the compatibility declaration, then rerun the full clean-guest lifecycle.
+Do not reclassify a failed mandatory check as a skip or reuse an older passing screenshot. Escalate unresolved
+dependency, GUI, provider, signing, or vendor behavior before restoring an availability claim.
+
+## Public verification and final activation
+
+After publication, compare every production HTTPS byte against the signed candidate:
+
+```bash
+python3 scripts/verify-opensuse-public.py \
+ --repository dist/opensuse-repository --public-key rpm-public.asc --fingerprint "$RPM_FPR" \
+ --base-url "$OPENSUSE_URL" --proof-url "$OPENSUSE_PROOF_URL" --output dist/opensuse-channel.json
+```
+
+The checker must validate the local signed chain, HTTPS responses without redirects, exact hashes/sizes, and target
+identity. Fixture CAs, synthetic upgrades, and local SSH rehearsals prove development behavior only.
+
+The workflow artifact `loopwire-opensuse-publication-RUN_ID` contains the publication report, repository manifest, and
+`opensuse-channel.json`. **Final activation is a human operation:** review that successful protected run and the
+initial production clean-client lifecycle, including the actual snapshot, URL, signer, RPM provenance, and versions.
+Copy the emitted record to `packaging/repositories/opensuse-channel.json` in a reviewed commit and deploy the website.
+Do not set `status` alone or paste fixture proof into production configuration.
+
+A verified schema-version-1 record requires the openSUSE target, HTTPS `baseUrl`, full uppercase fingerprint, lowercase
+64-character `revision`, ISO `verifiedAt`, and project GitHub Actions `proofUrl`. Snapshot and package-hash evidence
+live in the repository/VM proof manifests. Missing or invalid fields keep the existing signed direct-download command.
+Only a complete reviewed record changes the openSUSE tab to `sudo zypper install loopwire` with a separate setup link.
+
+## Development verification
+
+Run shared RPM protocol/publication/public-proof tests, the openSUSE bootstrap/workflow checks, channel tests, docs,
+and site/browser gates. Real Zypper tests must reject unsigned/changed metadata and RPMs, wrong signers, and incomplete
+promotion. The clean KVM lifecycle uses:
+
+```bash
+bash scripts/native-package-vm.sh run-opensuse-repo \
+ --target opensuse-tumbleweed --version 0.1.0 --release-dir dist/release
+bash scripts/native-package-vm.sh verify-opensuse-repo --target opensuse-tumbleweed
+node --test apps/site/src/lib/opensuseChannel.test.mjs
+```
+
+Require setup, install, reinstall, synthetic upgrade, explicit rollback/downgrade, package removal, and source removal,
+with candidate/origin/vendor and signature evidence plus the installed GUI and provider checks. Synthetic revisions
+reuse authenticated release payloads; they are not newly published application releases or public availability proof.
+This lifecycle does not promote openSUSE desktop-session or live audio-backend support.
diff --git a/apps/docs/docs/developer/release.md b/apps/docs/docs/developer/release.md
index eec7a4f..c8ada51 100644
--- a/apps/docs/docs/developer/release.md
+++ b/apps/docs/docs/developer/release.md
@@ -35,6 +35,21 @@ The Fedora homepage tab remains on its signed direct-download RPM while
tab to `sudo dnf install loopwire` and links the separate one-time setup procedure. The automatic installer continues
to work through the direct-download path, and fixture lifecycle evidence cannot activate the production channel.
+## Signed openSUSE channel
+
+Tumbleweed x86_64 publication has a separate [openSUSE operations runbook](./opensuse-repository.md). It selects a
+project-owned repository over OBS to preserve authenticated release-input provenance and the existing signing,
+retention, revision-check, and recovery contract. It requires both embedded RPM and repository-metadata signatures.
+
+The optional **Publish openSUSE Repository** workflow runs after GitHub Release publication only when
+`OPENSUSE_REPOSITORY_ENABLED=true`, using `packages-production`. Production hosting, signing/environment setup, first
+public verification, and reviewed channel activation remain human operations. Until then the openSUSE homepage keeps
+the authenticated direct-RPM fallback. Fedora and APT have independent gates.
+
+Record the Tumbleweed snapshot in lifecycle evidence. A later-snapshot failure blocks activation and must be resolved
+through a package repair or explicit compatibility correction followed by a full clean-guest lifecycle rerun. Do not
+promote synthetic upgrades or older snapshot evidence into a new public compatibility claim.
+
## Local Artifact Smoke
```bash
diff --git a/apps/docs/docs/guide/install.md b/apps/docs/docs/guide/install.md
index 15e5805..7a726d3 100644
--- a/apps/docs/docs/guide/install.md
+++ b/apps/docs/docs/guide/install.md
@@ -172,8 +172,17 @@ sha256sum --check --ignore-missing SHA256SUMS &&
sudo zypper install --allow-unsigned-rpm ./loopwire-0.1.0-1.x86_64.rpm
```
-[Repository work to shorten this setup](https://github.com/sandwichfarm/loopwire/issues/37) tracks signed metadata,
-release publication, clean-guest install/upgrade verification, and updated instructions.
+[openSUSE repository setup and availability](./opensuse-repository.md) covers the one-time key/source setup, normal
+updates, vendor and priority behavior, rollback, removal, and Tumbleweed snapshot compatibility. Once that page shows
+a verified public channel, complete setup and refresh before using:
+
+```bash
+sudo zypper install loopwire
+```
+
+This is a third-party project repository for Tumbleweed x86_64, not default-distro availability. Until activation,
+use Automatic or the authenticated direct download above. Its local-RPM signature exception never applies to the
+repository path, which requires both signed metadata and embedded RPM signatures.
## Nix / NixOS
@@ -286,9 +295,9 @@ Run the metadata smoke:
pnpm verify:packaging
```
-The AppImages and native deb/RPM files are published as direct downloads on the `v0.1.0` GitHub Release. The APT and
-Fedora repositories have separate [APT](./apt-repository.md) and [Fedora](./fedora-repository.md) public activation
-gates; the openSUSE repository remains planned.
+The AppImages and native deb/RPM files are published as direct downloads on the `v0.1.0` GitHub Release. The project
+repositories have separate [APT](./apt-repository.md), [Fedora](./fedora-repository.md), and
+[openSUSE](./opensuse-repository.md) public activation gates.
Their matching-guest proof command boots official,
checksum-pinned cloud images under KVM and stores local evidence without changing host audio:
diff --git a/apps/docs/docs/guide/opensuse-repository.md b/apps/docs/docs/guide/opensuse-repository.md
new file mode 100644
index 0000000..fd100e6
--- /dev/null
+++ b/apps/docs/docs/guide/opensuse-repository.md
@@ -0,0 +1,154 @@
+
+
+# openSUSE repository
+
+Loopwire's project-owned, third-party repository targets **openSUSE Tumbleweed on x86_64**. It requires one-time
+setup; Loopwire is not supplied by the default openSUSE repositories through this channel. This is not an OBS project
+or an openSUSE-maintained package. Leap and other architectures need their own validated packages; use the matching
+option in the [installation guide](./install.md).
+
+
+
Public channel pending
+
The repository implementation is available in the source tree, but its public URL and signing key have not been
+ activated. Use the signed openSUSE direct download or the automatic
+ installer. The setup command appears here only after production verification has been reviewed.
+
+
+
+
Verified public channel
+
Repository: {{ channel.baseUrl }}
+ OpenPGP fingerprint: {{ channel.signingFingerprint }}
+ Public verification record, recorded {{ channel.verifiedAt }}.
+
+
+## One-time setup
+
+Use this procedure once the page displays a verified public channel. You need Bash, curl, Python 3, GnuPG, RPM,
+Zypper, and sudo access. Download and inspect the setup helper first:
+
+```bash
+curl -fsSLo setup-opensuse-repository.sh \
+ https://raw.githubusercontent.com/sandwichfarm/loopwire/master/scripts/setup-opensuse-repository.sh
+less setup-opensuse-repository.sh
+```
+
+
+
Run it with the verified URL and complete OpenPGP fingerprint:
+
{{ setupCommand }}
+
+
+The helper accepts only Tumbleweed x86_64. It downloads the key over HTTPS and checks the complete fingerprint
+**before** configuring the source or refreshing metadata. It writes `/etc/zypp/repos.d/loopwire.repo` and
+`/etc/zypp/keys/loopwire-repository-FINGERPRINT.asc`. Repeating setup with the same inputs is safe; an unrelated
+definition using that filename is an error. Other sources are preserved. Add `--dry-run` and omit `sudo` to preview
+the target and managed paths without downloads or changes.
+
+The repository alias is `loopwire`, its type is `rpm-md`, and `autorefresh=1` enables normal metadata refresh.
+`gpgcheck=1`, `repo_gpgcheck=1`, and `pkg_gpgcheck=1` require signed metadata and signed RPMs. Priority stays at
+`99`; setup does not change global solver settings or automatically switch package vendors.
+The [libzypp configuration reference](https://manpages.opensuse.org/Tumbleweed/libzypp/zypp.conf.5.en.html) explains
+these separate signature settings.
+
+After successful setup, refresh the repository, compare any key prompt's full fingerprint with this page, and install:
+
+```bash
+sudo zypper refresh loopwire &&
+sudo zypper install loopwire
+```
+
+The helper does not run either command for you or automatically accept a Zypper key prompt. Reject a mismatched
+fingerprint. Confirm the candidate with `zypper info --repo loopwire loopwire` when needed. It must come from the
+configured URL and match the expected package version and x86_64 architecture. Installation includes the desktop and
+background/provider commands without enabling startup services or applying audio routes.
+
+## Updates, reinstall, and vendor selection
+
+To update only Loopwire after setup:
+
+```bash
+sudo zypper refresh loopwire &&
+sudo zypper update loopwire
+```
+
+For normal distribution-wide Tumbleweed updates, follow openSUSE's system-upgrade procedure. Do not run a
+distribution-wide vendor switch to install or update this one application.
+
+Inspect installed identity and available versions before repairing or changing the package:
+
+```bash
+rpm -q --qf '%{NAME} %{VERSION}-%{RELEASE} %{ARCH} vendor=%{VENDOR}\n' loopwire
+zypper search --details --repo loopwire --match-exact loopwire
+```
+
+To reinstall the same retained version, replace `VERSION-RELEASE` with the installed value:
+
+```bash
+sudo zypper install --force --no-allow-vendor-change --no-allow-arch-change \
+ --from loopwire 'loopwire=VERSION-RELEASE'
+```
+
+Saved Loopwire configurations are outside package ownership and are preserved. The RPM vendor is a header field
+carried from the authenticated release; it is separate from both repository origin and signing fingerprint. If an
+existing package came from another vendor, review that change explicitly. Only when intentionally migrating that
+package, use `sudo zypper install --allow-vendor-change --from loopwire loopwire`. Keep the normal global vendor
+protection enabled. [libzypp vendor protection](https://opensuse.github.io/libzypp/pg_zypp-solv-vendorchange.html)
+explains why switching repository URLs does not itself change a package's vendor.
+
+## Earlier versions
+
+Publishing an older recommended package set does not downgrade an installed newer version. After maintainers
+recommend a rollback, choose the exact version listed by the search command above:
+
+```bash
+sudo zypper refresh loopwire &&
+sudo zypper install --oldpackage --no-allow-vendor-change --no-allow-arch-change \
+ --from loopwire 'loopwire=VERSION-RELEASE'
+```
+
+This permits a downgrade while preserving signature, dependency, vendor, and architecture checks. Do not substitute
+a Fedora or Leap RPM. If dependency resolution fails on your Tumbleweed snapshot, stop and report it instead
+of ignoring dependencies. See the [official Zypper reference](https://manpages.opensuse.org/Tumbleweed/zypper/zypper.8.en.html)
+for exact-version installation and `--oldpackage` behavior.
+
+## Remove the package or repository
+
+Use `sudo zypper remove loopwire` to remove package-owned files while keeping saved configuration. Remove any startup
+integration you enabled separately, using the [start-on-boot guide](./start-on-boot.md).
+
+To stop using the repository, run the same inspected helper:
+
+```bash
+sudo bash setup-opensuse-repository.sh --remove
+```
+
+It removes the managed `.repo` file and its referenced Loopwire public-key file. It does not uninstall Loopwire,
+alter other repositories, or erase keys already accepted into the RPM database or libzypp key cache. Inspect
+`zypper repos --details` to confirm that alias `loopwire` is absent. Keys in shared databases need separate careful
+cleanup by exact identity if required; do not remove a shared distro key or match only a short key ID.
+
+## Tumbleweed compatibility and trust
+
+Tumbleweed is rolling. A passing repository test proves the recorded snapshot and package set, not every future
+snapshot. If a later snapshot fails installation, launch, provider checks, or the package lifecycle, maintainers must
+withhold activation, publish the limitation, and repair the package or compatibility statement before rerunning the
+complete clean-guest lifecycle. Report the `VERSION_ID` from `/etc/os-release`, exact package version, candidate
+repository, and error output. [The compatibility policy](../developer/opensuse-repository.md#rolling-snapshot-policy)
+describes that gate.
+
+The OpenPGP repository key authenticates the distributed RPM and metadata. It is separate from the OpenSSL key that
+authenticates direct GitHub Release checksums. The local-RPM `--allow-unsigned-rpm` exception belongs only to the
+authenticated direct-download fallback; never add it or `--no-gpg-checks` to repository commands.
+
+For a routine key change, verify the successor's full fingerprint through trusted project announcements before
+rerunning setup with the new URL and fingerprint. For a compromised or revoked key, remove the repository immediately
+and follow the incident notice. Publishing a revocation certificate does not update every existing client's key cache.
+
+Zypper validates signatures, but a valid signature alone cannot distinguish replayed older metadata. Loopwire's
+publication checks enforce a signed project verification deadline; client autorefresh does not enforce that custom
+deadline. Report unexpected rollback, metadata-signature failures, checksum mismatches, or missing objects and retry
+only after the repository is repaired. Do not disable TLS, signature, or dependency checks to continue.
diff --git a/apps/docs/docs/guide/support-matrix.md b/apps/docs/docs/guide/support-matrix.md
index 01c6397..bb5b65d 100644
--- a/apps/docs/docs/guide/support-matrix.md
+++ b/apps/docs/docs/guide/support-matrix.md
@@ -74,7 +74,8 @@ the Tauri shell command bridge.
| Ubuntu 24.04 / Debian 13 signed APT repository, amd64 | Separate signed-metadata, publication, bootstrap, and clean-guest lifecycle checks | Public activation is gated; see [current channel availability](./apt-repository.md). |
| Fedora 44 RPM | Verified in a matching KVM guest at commit `70eee4e`; review snapshot in `vm/native-package-proof/` | Published as a direct download. |
| Fedora 44 signed project repository, x86_64 | Signed-RPM/metadata, publication, bootstrap, and clean-guest lifecycle checks | Public activation is gated; see [current channel availability](./fedora-repository.md). |
-| openSUSE Tumbleweed RPM | Verified in a matching KVM guest at commit `70eee4e`; review snapshot in `vm/native-package-proof/` | Published as a direct download; no OBS repository. |
+| openSUSE Tumbleweed RPM | Verified in a matching KVM guest at commit `70eee4e`; review snapshot in `vm/native-package-proof/` | Published as a direct download. |
+| openSUSE Tumbleweed signed project repository, x86_64 | Signed-RPM/metadata, publication, bootstrap, and snapshot-bound clean-guest lifecycle checks | Public activation is gated; see [current channel availability](./opensuse-repository.md). No OBS project is advertised. |
| AUR `loopwire` | Tagged source build through `pnpm verify:aur:source` | Published for 0.1.0. |
| AUR `loopwire-bin` | Signed release-artifact build through `pnpm verify:aur` | Published for 0.1.0. |
| AUR `loopwire-git` | Rolling default-branch build through `pnpm verify:aur:git` | Published; development snapshots are not stable releases. |
@@ -94,6 +95,12 @@ publication recovery, and install/reinstall/upgrade/downgrade/removal behavior o
Synthetic versions and local HTTPS fixtures are development evidence. They do not prove the production URL is live or
promote Fedora desktop/audio support. Only a reviewed production verification record activates the short DNF command.
+openSUSE repository proof is scoped to the recorded Tumbleweed snapshot on x86_64. It covers the signed repository
+and installed package lifecycle, including explicit downgrade and source removal, separately from desktop/audio
+support. Leap, ARM64, and later rolling snapshots are not implied. A failed newer-snapshot run blocks activation until
+the package or compatibility statement is repaired and the full lifecycle passes again; see the
+[rolling snapshot policy](../developer/opensuse-repository.md#rolling-snapshot-policy).
+
Native package verification is narrower than audio-backend support. The committed snapshot proves that each official,
checksum-pinned guest built and installed its target package, ran the packaged background/provider/backend commands,
resolved GUI libraries, created a Loopwire X11 window under Xvfb, and removed all package-owned files. It does not
diff --git a/apps/docs/docs/release-notes/unreleased.md b/apps/docs/docs/release-notes/unreleased.md
index 767f5e9..ef79c69 100644
--- a/apps/docs/docs/release-notes/unreleased.md
+++ b/apps/docs/docs/release-notes/unreleased.md
@@ -30,6 +30,20 @@ These notes describe source-tree progress. They are not a public release announc
- Added [Fedora user setup and rollback guidance](../guide/fedora-repository.md) and the
[maintainer operations runbook](../developer/fedora-repository.md).
+## Signed openSUSE repository development
+
+- Selected a project-owned repository over OBS to retain authenticated release-input identity, signing, atomic
+ metadata promotion, indefinite retention, and recovery under the existing release workflow.
+- Added the Tumbleweed x86_64 target with authenticated metadata and embedded RPM signatures, protected publication,
+ repeat-safe setup/removal, and a dedicated clean-guest lifecycle. Failed later-snapshot checks block activation
+ until compatibility is repaired and the lifecycle reruns.
+- The openSUSE homepage switches to `sudo zypper install loopwire` only with a complete reviewed public verification
+ record. Production provisioning and initial activation remain human work; direct downloads and Automatic stay
+ functional while the channel is pending.
+- Added [openSUSE user setup and recovery](../guide/opensuse-repository.md) and the
+ [maintainer runbook](../developer/opensuse-repository.md), including priority/vendor behavior, key changes, downgrade,
+ and rolling-snapshot policy.
+
## Other distribution updates
- Root-level Bunny deployments now pass the post-upload live-site check when `BUNNY_REMOTE_PREFIX` is intentionally
diff --git a/apps/site/src/lib/opensuseChannel.test.mjs b/apps/site/src/lib/opensuseChannel.test.mjs
new file mode 100644
index 0000000..cb4b69b
--- /dev/null
+++ b/apps/site/src/lib/opensuseChannel.test.mjs
@@ -0,0 +1,83 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+import { fedoraInstallOption, opensuseInstallOption, verifiedFedoraChannel, verifiedOpenSuseChannel } from "./rpmChannel.mjs";
+
+const verified = {
+ schemaVersion: 1, status: "verified", target: "opensuse-tumbleweed",
+ baseUrl: "https://packages.example.test/opensuse/tumbleweed/x86_64/",
+ signingFingerprint: "ABCDEF0123456789ABCDEF0123456789ABCDEF01", revision: "a".repeat(64),
+ verifiedAt: "2026-09-05T10:20:30+00:00",
+ proofUrl: "https://github.com/sandwichfarm/loopwire/actions/runs/123456"
+};
+const manual = {
+ id: "opensuse", command: "sudo zypper install --allow-unsigned-rpm ./loopwire-0.1.0-1.x86_64.rpm",
+ note: "Authenticate the signed release checksum before installing this local RPM.",
+ detail: "Use the matching portable path on other targets.",
+ href: "/docs/guide/opensuse-repository.html", link: "openSUSE repository setup and availability"
+};
+
+test("pending or incomplete openSUSE records retain the authenticated direct-RPM fallback", () => {
+ for (const record of [null, undefined, {}, [], { ...verified, status: "pending" }]) {
+ assert.equal(verifiedOpenSuseChannel(record), null);
+ assert.equal(opensuseInstallOption(record, manual), manual);
+ }
+ for (const key of Object.keys(verified)) {
+ const record = { ...verified };
+ delete record[key];
+ assert.equal(verifiedOpenSuseChannel(record), null, `missing ${key}`);
+ assert.equal(opensuseInstallOption(record, manual), manual);
+ }
+});
+
+test("verified openSUSE fixture exposes normal Zypper install and a separate setup link", () => {
+ assert.equal(verifiedOpenSuseChannel(verified).baseUrl, "https://packages.example.test/opensuse/tumbleweed/x86_64");
+ const option = opensuseInstallOption(verified, manual);
+ assert.equal(option.id, "opensuse");
+ assert.equal(option.command, "sudo zypper install loopwire");
+ assert.equal(option.href, "/docs/guide/opensuse-repository.html#one-time-setup");
+ assert.doesNotMatch(`${option.note} ${option.detail}`, /workflow|revision|activation|operator/);
+ assert.match(manual.command, /--allow-unsigned-rpm/);
+});
+
+test("Fedora and openSUSE verification records cannot activate each other's install option", () => {
+ const fedora = { ...verified, target: "fedora-44" };
+ assert.ok(verifiedFedoraChannel(fedora));
+ assert.equal(verifiedFedoraChannel(verified), null);
+ assert.equal(verifiedOpenSuseChannel(fedora), null);
+ assert.equal(fedoraInstallOption(verified, manual), manual);
+ assert.equal(opensuseInstallOption(fedora, manual), manual);
+});
+
+test("malformed or unsupported openSUSE proof records never activate repository instructions", () => {
+ const invalid = {
+ schemaVersion: [0, "1"], status: [true, "ready"],
+ target: ["opensuse-leap", "opensuse-tumbleweed-aarch64", "opensuse-tumbleweed-x86_64", null],
+ baseUrl: ["http://packages.example.test", "https://user:pass@packages.example.test", "not a URL",
+ "https://packages.example.test?", "https://packages.example.test#", "https://packages.example.test?q=1",
+ "https://packages.example.test:0", "https://packages.example.test:65536", " https://packages.example.test",
+ "https://packages.example.test/\n", "https://packages.example.test/$(id)", "https://packages.example.test/'"],
+ signingFingerprint: ["a".repeat(40), "A".repeat(39), "G".repeat(40)],
+ revision: ["A".repeat(64), "a".repeat(63)],
+ verifiedAt: ["yesterday", "2026-09-05", "2026-02-30T00:00:00Z", "2026-09-05T25:00:00Z"],
+ proofUrl: ["https://github.com/other/repo/actions/runs/123", "https://github.com/sandwichfarm/loopwire/pull/37",
+ "https://github.com/sandwichfarm/loopwire/actions/runs/123?fixture=1"]
+ };
+ for (const [key, values] of Object.entries(invalid)) {
+ for (const value of values) {
+ const record = { ...verified, [key]: value };
+ assert.equal(verifiedOpenSuseChannel(record), null, `${key}: ${value}`);
+ assert.equal(opensuseInstallOption(record, manual), manual);
+ }
+ }
+});
+
+test("checked-in openSUSE channel remains pending or has a complete verification record", () => {
+ const channel = JSON.parse(readFileSync(new URL("../../../../packaging/repositories/opensuse-channel.json", import.meta.url), "utf8"));
+ if (channel.status === "pending") {
+ assert.deepEqual(channel, { schemaVersion: 1, status: "pending", target: null, baseUrl: null,
+ signingFingerprint: null, revision: null, verifiedAt: null, proofUrl: null });
+ } else {
+ assert.ok(verifiedOpenSuseChannel(channel));
+ }
+});
diff --git a/apps/site/src/lib/rpmChannel.mjs b/apps/site/src/lib/rpmChannel.mjs
index 1d8bb88..df9e83b 100644
--- a/apps/site/src/lib/rpmChannel.mjs
+++ b/apps/site/src/lib/rpmChannel.mjs
@@ -1,13 +1,23 @@
/**
- * Fedora repository instructions are advertised only after the complete public
+ * RPM repository instructions are advertised only after the complete public
* verification record for the supported target has been reviewed and committed.
* Invalid or incomplete records preserve the signed direct-download option.
* @param {unknown} value
*/
export function verifiedFedoraChannel(value) {
+ return verifiedRpmChannel(value, "fedora-44");
+}
+
+/** @param {unknown} value */
+export function verifiedOpenSuseChannel(value) {
+ return verifiedRpmChannel(value, "opensuse-tumbleweed");
+}
+
+/** @param {unknown} value @param {string} target */
+function verifiedRpmChannel(value, target) {
if (!value || typeof value !== "object") return null;
const channel = /** @type {Record} */ (value);
- if (channel.schemaVersion !== 1 || channel.status !== "verified" || channel.target !== "fedora-44" ||
+ if (channel.schemaVersion !== 1 || channel.status !== "verified" || channel.target !== target ||
typeof channel.baseUrl !== "string" || !validBaseUrl(channel.baseUrl) ||
typeof channel.signingFingerprint !== "string" || !/^[A-F0-9]{40}$/.test(channel.signingFingerprint) ||
typeof channel.revision !== "string" || !/^[a-f0-9]{64}$/.test(channel.revision) ||
@@ -63,3 +73,21 @@ export function fedoraInstallOption(channel, manual) {
link: "Set up the Fedora repository"
};
}
+
+/**
+ * @template {{command: string, note: string, detail: string, href: string, link: string}} T
+ * @param {unknown} channel
+ * @param {T} manual
+ * @returns {T}
+ */
+export function opensuseInstallOption(channel, manual) {
+ if (!verifiedOpenSuseChannel(channel)) return manual;
+ return {
+ ...manual,
+ command: "sudo zypper install loopwire",
+ note: "After one-time setup, install and update Loopwire through its signed openSUSE repository.",
+ detail: "For openSUSE Tumbleweed on x86_64. Check the guide for rolling-release compatibility.",
+ href: "/docs/guide/opensuse-repository.html#one-time-setup",
+ link: "Set up the openSUSE repository"
+ };
+}
diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro
index 38acfd9..1d545bc 100644
--- a/apps/site/src/pages/index.astro
+++ b/apps/site/src/pages/index.astro
@@ -3,8 +3,9 @@ import SiteLayout from "../layouts/SiteLayout.astro";
import screenshot from "../../../../assets/product-screenshot.png";
import aptChannel from "../../../../packaging/repositories/apt-channel.json";
import fedoraChannel from "../../../../packaging/repositories/fedora-channel.json";
+import opensuseChannel from "../../../../packaging/repositories/opensuse-channel.json";
import { aptInstallOption } from "../lib/aptChannel.mjs";
-import { fedoraInstallOption } from "../lib/rpmChannel.mjs";
+import { fedoraInstallOption, opensuseInstallOption } from "../lib/rpmChannel.mjs";
const title = "Loopwire | Linux virtual audio routing";
const description =
@@ -72,15 +73,15 @@ const installOptions = [
"handles verification for you. The signed DNF repository is pending public verification.",
href: "/docs/guide/fedora-repository.html", link: "Fedora repository setup and availability"
}),
- {
+ opensuseInstallOption(opensuseChannel, {
id: "opensuse", label: "openSUSE", heading: "openSUSE Tumbleweed · x86_64",
command: nativeInstall("loopwire-0.1.0-1.x86_64.rpm", "sudo zypper install --allow-unsigned-rpm ./loopwire-0.1.0-1.x86_64.rpm"),
note: "Manual signed v0.1.0 download. Run these steps together in an empty folder. The signed checksum " +
"authenticates the RPM before Zypper installs it.",
detail: "The RPM has no embedded signature; the exception applies to this verified file. Automatic handles " +
- "verification for you. A signed repository is planned.",
- href: "https://github.com/sandwichfarm/loopwire/issues/37", link: "Track simpler openSUSE installs"
- },
+ "verification for you.",
+ href: "/docs/guide/opensuse-repository.html", link: "openSUSE repository setup and availability"
+ }),
{
id: "nix", label: "Nix / NixOS", heading: "Nix · user profile",
command: 'nix --extra-experimental-features "nix-command flakes" profile install github:sandwichfarm/loopwire#loopwire-bin',
diff --git a/package.json b/package.json
index 08efc2c..17b3572 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"build:site": "pnpm --filter @loopwire/site build",
"build:web": "pnpm build:site && pnpm build:docs && node scripts/build-static-site.mjs",
"check": "pnpm check:verify && pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm verify:site",
- "check:verify": "pnpm verify:requirements && pnpm verify:docs && pnpm test:setup-github && pnpm verify:scripts && pnpm verify:workflows && pnpm verify:runtime && pnpm verify:tauri && pnpm verify:apt && pnpm verify:rpm-repository",
+ "check:verify": "pnpm verify:requirements && pnpm verify:docs && pnpm test:setup-github && pnpm verify:scripts && pnpm verify:workflows && pnpm verify:runtime && pnpm verify:tauri && pnpm verify:apt && pnpm verify:rpm-repository && pnpm verify:opensuse-repository",
"collect:evidence": "node scripts/collect-release-evidence.mjs",
"collect:support": "node scripts/collect-support-bundle.mjs",
"release:handoff": "bash scripts/plan-final-release-handoff.sh",
@@ -68,6 +68,7 @@
"verify:packaging": "bash scripts/verify-packaging.sh",
"verify:apt": "bash scripts/verify-apt-repository.sh",
"verify:rpm-repository": "bash scripts/verify-rpm-repository.sh",
+ "verify:opensuse-repository": "bash scripts/verify-opensuse-repository.sh",
"verify:native-packaging": "bash scripts/verify-native-packaging.sh",
"build:portable-linux": "bash scripts/build-portable-linux-binary.sh",
"package:deb": "bash scripts/build-deb-package.sh",
diff --git a/packaging/README.md b/packaging/README.md
index ce0d11a..c4f8c1e 100644
--- a/packaging/README.md
+++ b/packaging/README.md
@@ -190,6 +190,42 @@ fingerprint from the [gated Fedora repository guide](../apps/docs/docs/guide/fed
placeholder. See the [maintainer runbook](../apps/docs/docs/developer/fedora-repository.md) for the provider decision,
production layout, protected configuration, publication/recovery, rollback, caching, key rotation, and activation.
+## Signed openSUSE repository
+
+The project-owned openSUSE channel targets Tumbleweed x86_64 at `/opensuse/tumbleweed/x86_64/`. It authenticates the
+existing release RPM with the project's OpenSSL-signed checksum manifest, signs a staged copy with a dedicated
+OpenPGP repository key, and publishes authenticated RPM metadata. The original GitHub Release file stays unchanged;
+the manifest records source and distributed hashes. This approach was selected over OBS to preserve the current
+release identity and publication/recovery proof. It does not provision or advertise an OBS project.
+
+The input directory needs the signed release checksum manifest plus `release-assets.json`, the x86_64 portable
+archive, and the exact openSUSE RPM. Their authenticated inventory binds the release tag, full source commit, and
+source/build hashes; a lone RPM and checksum entry do not satisfy the openSUSE provenance contract.
+
+Use the explicit command-line target for the shared repository generator and publisher:
+
+```bash
+python3 scripts/rpm-repository.py build --target opensuse-tumbleweed-x86_64 \
+ --release-dir dist/release --version 0.1.0 --output dist/opensuse-repository \
+ --signing-key "$RPM_FPR" --gnupg-home "$RPM_GNUPG_HOME" \
+ --release-public-key packaging/release-signing-public.pem
+python3 scripts/rpm-repository.py verify --target opensuse-tumbleweed-x86_64 \
+ --repository dist/opensuse-repository --public-key rpm-public.asc --fingerprint "$RPM_FPR"
+```
+
+The publisher keeps this target's private state under `ROOT/channels/opensuse-tumbleweed-x86_64` and its served
+objects under `ROOT/public/opensuse/tumbleweed/x86_64`. The protected workflow uses `OPENSUSE_REPOSITORY_ENABLED` and
+the `packages-production` environment. Origin, signing key, credentials, public proof, and first activation remain
+human operations. The checked-in `packaging/repositories/opensuse-channel.json` stays pending until its reviewed
+production proof record exists.
+
+The helper writes only the managed Zypper source and fingerprint-named key, verifies the complete fingerprint before
+refresh, and preserves `gpgcheck=1`, `repo_gpgcheck=1`, `pkg_gpgcheck=1`, priority 99, and normal vendor protection.
+User instructions come from the [gated openSUSE guide](../apps/docs/docs/guide/opensuse-repository.md). The
+[operator runbook](../apps/docs/docs/developer/opensuse-repository.md) covers publishing, rollback, retained snapshots,
+rotation, removal, and the required newer-snapshot compatibility rerun. A failed newer Tumbleweed snapshot blocks
+activation; one passing snapshot does not establish support for all future rolling updates.
+
## Native deb and RPM packages
The native package recipes install the complete canonical payload: GUI, background restore, DSP and JACK provider
diff --git a/packaging/repositories/Dockerfile.opensuse-rpm-tools b/packaging/repositories/Dockerfile.opensuse-rpm-tools
new file mode 100644
index 0000000..c678ee9
--- /dev/null
+++ b/packaging/repositories/Dockerfile.opensuse-rpm-tools
@@ -0,0 +1,9 @@
+FROM opensuse/tumbleweed@sha256:b6821dbfad5422b663e0eeae8241a30ef2976e1e9ae4a2f827641c0eecf7e4fd
+
+RUN zypper --non-interactive refresh \
+ && zypper --non-interactive install --no-recommends \
+ cpio createrepo_c gh git gpg2 nginx nodejs24 openssh openssl python3 rpm-build ruby ShellCheck zypper \
+ && zypper clean --all
+
+ENV PYTHONDONTWRITEBYTECODE=1
+WORKDIR /workspace
diff --git a/packaging/repositories/nginx-rpm.conf b/packaging/repositories/nginx-rpm.conf
index e12e8fe..f14965b 100644
--- a/packaging/repositories/nginx-rpm.conf
+++ b/packaging/repositories/nginx-rpm.conf
@@ -1,6 +1,7 @@
# Include these locations in a TLS-enabled server. The SSH publisher writes to
# /srv/loopwire-rpm; only its public child is served. snapshots/ and state/ stay
-# private. The DNF base URL is https://HOST/fedora/44/x86_64/.
+# private. Base URLs are https://HOST/fedora/44/x86_64/ and
+# https://HOST/opensuse/tumbleweed/x86_64/.
root /srv/loopwire-rpm/public;
autoindex off;
disable_symlinks on;
@@ -19,6 +20,12 @@ location ~ "^/fedora/44/x86_64/(packages/loopwire-[A-Za-z0-9][A-Za-z0-9.+_~]*-1\
add_header Cache-Control "public, max-age=31536000, immutable";
}
+location ~ "^/opensuse/tumbleweed/x86_64/(packages/loopwire-[A-Za-z0-9][A-Za-z0-9.+_~]*-1\.x86_64\.rpm|keys/([A-F0-9]{40}|[A-F0-9]{64})\.asc|repodata/[0-9a-f]{64}-(primary|filelists|other)\.xml\.gz)$" {
+ try_files $uri =404;
+ error_page 404 = @rpm_missing;
+ add_header Cache-Control "public, max-age=31536000, immutable";
+}
+
location @rpm_missing {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
return 404;
@@ -26,10 +33,17 @@ location @rpm_missing {
# repomd.xml is the commit point. The publisher atomically replaces its
# detached signature first and repomd.xml last. Never cache either file: a
-# request sequence that crosses publication fails closed under repo_gpgcheck=1
-# and a retry obtains the matched pair. No CDN-wide purge is required.
+# request sequence that crosses publication fails closed under DNF or libzypp
+# metadata-signature verification, and a retry obtains the matched pair. No
+# CDN-wide purge is required.
location /fedora/44/x86_64/ {
try_files $uri =404;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
etag off;
}
+
+location /opensuse/tumbleweed/x86_64/ {
+ try_files $uri =404;
+ add_header Cache-Control "no-store, no-cache, must-revalidate" always;
+ etag off;
+}
diff --git a/packaging/repositories/opensuse-channel.json b/packaging/repositories/opensuse-channel.json
new file mode 100644
index 0000000..5aad5fd
--- /dev/null
+++ b/packaging/repositories/opensuse-channel.json
@@ -0,0 +1,10 @@
+{
+ "schemaVersion": 1,
+ "status": "pending",
+ "target": null,
+ "baseUrl": null,
+ "signingFingerprint": null,
+ "revision": null,
+ "verifiedAt": null,
+ "proofUrl": null
+}
diff --git a/packaging/vm/guest-opensuse-repository-smoke.sh b/packaging/vm/guest-opensuse-repository-smoke.sh
new file mode 100755
index 0000000..4c01bb5
--- /dev/null
+++ b/packaging/vm/guest-opensuse-repository-smoke.sh
@@ -0,0 +1,392 @@
+#!/usr/bin/env bash
+# The unprivileged guest user owns proof logs; sudo applies only to package and trust-store commands.
+# shellcheck disable=SC2024
+set -euo pipefail
+
+target="${1:?target is required}"
+package_target="${2:?package target is required}"
+format="${3:?format is required}"
+version="${4:?version is required}"
+git_head="${5:?git head is required}"
+kit_dir="${6:-$PWD}"
+[ "$target" = opensuse-tumbleweed ] || { echo "unsupported openSUSE repository guest target" >&2; exit 2; }
+[ "$package_target" = opensuse-tumbleweed ] && [ "$format" = rpm ]
+[[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\+[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$ ]]
+[[ "$git_head" =~ ^[0-9a-f]{40}$ ]]
+cd "$kit_dir"
+
+proof_dir="$kit_dir/proof"
+fixture_dir="$kit_dir/opensuse-repository-fixture"
+release_dir="$kit_dir/release"
+public_release_proof="$proof_dir/public-release"
+base_url="https://127.0.0.1:8445/opensuse/tumbleweed/x86_64"
+upgrade_version="${version}+zypperfixture1"
+[[ "$version" != *+* ]] || upgrade_version="${version}.zypperfixture1"
+baseline_package_version="${version}-1"
+upgrade_package_version="${upgrade_version}-1"
+baseline_package="loopwire-${baseline_package_version}.x86_64.rpm"
+upgrade_package="loopwire-${upgrade_package_version}.x86_64.rpm"
+mkdir -p "$proof_dir/packages" "$proof_dir/repositories" "$public_release_proof" "$fixture_dir"
+exec > >(tee "$proof_dir/commands.log") 2>&1
+set -x
+
+cat /etc/os-release >"$proof_dir/os-release"
+uname -a >"$proof_dir/uname.txt"
+systemd-detect-virt --vm >"$proof_dir/virtualization.txt"
+grep -Eq '^(kvm|qemu)$' "$proof_dir/virtualization.txt"
+if rpm -q loopwire >/dev/null 2>&1; then
+ echo 'clean guest already has Loopwire installed' >&2
+ exit 1
+fi
+printf 'absent\n' >"$proof_dir/initial-package-status.txt"
+
+# Authenticate the public GitHub Release before using its openSUSE RPM as repository input.
+openssl dgst -sha256 -verify packaging/release-signing-public.pem \
+ -signature "$release_dir/SHA256SUMS.sig" "$release_dir/SHA256SUMS"
+python3 - "$release_dir" "$version" "$baseline_package" >"$proof_dir/public-release-validation.json" <<'PY'
+import hashlib, json, pathlib, re, sys
+root, version, rpm_name = pathlib.Path(sys.argv[1]), sys.argv[2], sys.argv[3]
+selected = [rpm_name, "loopwire-linux-x86_64.tar.gz", "release-assets.json"]
+entries = {}
+for number, line in enumerate((root / "SHA256SUMS").read_text().splitlines(), 1):
+ match = re.fullmatch(r"([0-9a-f]{64}) ([^/\\\s]+)", line)
+ assert match and match.group(2) not in entries, f"invalid or duplicate SHA256SUMS entry at line {number}"
+ entries[match.group(2)] = match.group(1)
+for name in selected:
+ assert name in entries, f"SHA256SUMS must contain exactly one {name} entry"
+ data = (root / name).read_bytes()
+ assert hashlib.sha256(data).hexdigest() == entries[name], f"signed checksum mismatch: {name}"
+manifest = json.loads((root / "release-assets.json").read_text())
+assert set(manifest) == {"schema", "release", "artifacts"} and manifest["schema"] == "loopwire.release-assets.v1"
+release = manifest["release"]
+assert set(release) == {"tag", "version", "gitHead"}
+assert release["tag"] == f"v{version}" and release["version"] == version
+assert re.fullmatch(r"[0-9a-f]{40}", release["gitHead"])
+assert isinstance(manifest["artifacts"], list)
+opensuse = [item for item in manifest["artifacts"] if isinstance(item, dict) and item.get("target") == "opensuse-tumbleweed"]
+assert len(opensuse) == 1 and set(opensuse[0]) == {"name", "kind", "target", "architecture", "bytes", "sha256"}
+rpm = opensuse[0]
+assert (rpm["name"], rpm["kind"], rpm["target"], rpm["architecture"]) == (rpm_name, "native-rpm", "opensuse-tumbleweed", "x86_64")
+assert rpm["bytes"] == (root / rpm_name).stat().st_size and rpm["sha256"] == entries[rpm_name]
+portable = [item for item in manifest["artifacts"] if isinstance(item, dict)
+ and item.get("name") == "loopwire-linux-x86_64.tar.gz"]
+assert len(portable) == 1 and portable[0].get("kind") == "portable-archive"
+assert portable[0].get("target") == "linux-generic" and portable[0].get("architecture") == "x86_64"
+assert portable[0].get("bytes") == (root / selected[1]).stat().st_size and portable[0].get("sha256") == entries[selected[1]]
+print(json.dumps({"status": "verified", "releaseGitHead": release["gitHead"],
+ "rpmSha256": entries[rpm_name], "tarSha256": entries[selected[1]],
+ "manifestSha256": entries[selected[2]]}, sort_keys=True))
+PY
+tar -xOf "$release_dir/loopwire-linux-x86_64.tar.gz" RELEASE >"$proof_dir/payload-release.txt"
+python3 - "$proof_dir/payload-release.txt" "$version" <<'PY'
+import pathlib, re, sys
+values = {}
+for line in pathlib.Path(sys.argv[1]).read_text().splitlines():
+ assert "=" in line
+ key, value = line.split("=", 1)
+ assert key not in values
+ values[key] = value
+assert set(values) == {"name", "version", "arch", "source_date_epoch"}
+assert values["name"] == "loopwire" and values["version"] == sys.argv[2] and values["arch"] == "x86_64"
+assert re.fullmatch(r"[0-9]+", values["source_date_epoch"])
+PY
+public_release_git_head="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["releaseGitHead"])' "$proof_dir/public-release-validation.json")"
+printf '%s\n' "$public_release_git_head" >"$proof_dir/public-release-git-head.txt"
+for release_file in "$baseline_package" loopwire-linux-x86_64.tar.gz SHA256SUMS SHA256SUMS.sig release-assets.json; do
+ cp "$release_dir/$release_file" "$public_release_proof/"
+done
+cp packaging/release-signing-public.pem "$public_release_proof/"
+cp "$proof_dir/payload-release.txt" "$public_release_proof/RELEASE"
+(cd "$release_dir" && sha256sum loopwire-linux-x86_64.tar.gz) >"$proof_dir/release-payload.sha256"
+
+sudo zypper --non-interactive refresh
+sudo zypper --non-interactive install --no-recommends \
+ ca-certificates ca-certificates-mozilla cpio createrepo_c curl findutils gpg2 gzip nodejs openssl \
+ python3 rpm-build tar xdotool xorg-x11-server-Xvfb
+
+# Private fixture keys stay inside the disposable guest and are never copied into proof.
+gnupg_home="$fixture_dir/gnupg"
+mkdir -m 0700 "$gnupg_home"
+gpg --homedir "$gnupg_home" --batch --pinentry-mode loopback --passphrase '' \
+ --quick-generate-key 'Loopwire disposable openSUSE repository guest fixture' rsa3072 sign 0
+fingerprint="$(gpg --homedir "$gnupg_home" --batch --with-colons --list-keys |
+ awk -F: '$1 == "fpr" { print $10; exit }')"
+[[ "$fingerprint" =~ ^[0-9A-F]{40}$ ]]
+gpg --homedir "$gnupg_home" --batch --armor --export "$fingerprint" >"$proof_dir/repository-key.asc"
+openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$fixture_dir/synthetic-release-key.pem"
+openssl pkey -in "$fixture_dir/synthetic-release-key.pem" -pubout -out "$fixture_dir/synthetic-release-public.pem"
+cp "$fixture_dir/synthetic-release-public.pem" "$proof_dir/synthetic-release-public.pem"
+
+upgrade_release="$fixture_dir/upgrade-release"
+mkdir -p "$upgrade_release"
+SOURCE_DATE_EPOCH=0 bash scripts/build-rpm-package.sh --target opensuse-tumbleweed \
+ --version "$upgrade_version" --arch x86_64 --release-dir "$release_dir" --output-dir "$upgrade_release"
+[ -f "$upgrade_release/$upgrade_package" ]
+[ "$(find "$upgrade_release" -maxdepth 1 -type f -name '*.rpm' | wc -l)" -eq 1 ]
+upgrade_source_sha256="$(sha256sum "$upgrade_release/$upgrade_package" | awk '{ print $1 }')"
+cp "$release_dir/loopwire-linux-x86_64.tar.gz" "$upgrade_release/"
+python3 - "$upgrade_release" "$upgrade_version" "$upgrade_package" "$public_release_git_head" <<'PY'
+import hashlib, json, pathlib, sys
+root, version, rpm_name, revision = pathlib.Path(sys.argv[1]), sys.argv[2], sys.argv[3], sys.argv[4]
+def artifact(name, kind, target):
+ data = (root / name).read_bytes()
+ return {"name": name, "kind": kind, "target": target, "architecture": "x86_64",
+ "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
+manifest = {"schema": "loopwire.release-assets.v1",
+ "release": {"tag": f"v{version}", "version": version, "gitHead": revision},
+ "artifacts": [artifact(rpm_name, "native-rpm", "opensuse-tumbleweed"),
+ artifact("loopwire-linux-x86_64.tar.gz", "portable-archive", "linux-generic")]}
+(root / "release-assets.json").write_text(json.dumps(manifest, indent=2) + "\n")
+PY
+(
+ cd "$upgrade_release"
+ sha256sum "$upgrade_package" loopwire-linux-x86_64.tar.gz release-assets.json >SHA256SUMS
+)
+openssl dgst -sha256 -sign "$fixture_dir/synthetic-release-key.pem" \
+ -out "$upgrade_release/SHA256SUMS.sig" "$upgrade_release/SHA256SUMS"
+baseline_source_sha256="$(sha256sum "$release_dir/$baseline_package" | awk '{ print $1 }')"
+printf 'baseline\t%s\t%s\nupgraded\t%s\t%s\n' \
+ "$baseline_package" "$baseline_source_sha256" "$upgrade_package" "$upgrade_source_sha256" \
+ >"$proof_dir/release-sources.tsv"
+
+python3 scripts/rpm-repository.py build --target opensuse-tumbleweed-x86_64 \
+ --release-dir "$release_dir" --version "$version" --output "$fixture_dir/initial" \
+ --signing-key "$fingerprint" --gnupg-home "$gnupg_home"
+python3 scripts/rpm-repository.py build --target opensuse-tumbleweed-x86_64 \
+ --release-dir "$upgrade_release" --version "$upgrade_version" --output "$fixture_dir/upgraded" \
+ --previous "$fixture_dir/initial" --signing-key "$fingerprint" --gnupg-home "$gnupg_home" \
+ --release-public-key "$fixture_dir/synthetic-release-public.pem"
+python3 scripts/rpm-repository.py rollback --target opensuse-tumbleweed-x86_64 \
+ --repository "$fixture_dir/initial" --output "$fixture_dir/rolled-back" \
+ --signing-key "$fingerprint" --gnupg-home "$gnupg_home"
+
+for repository_stage in initial upgraded rolled-back; do
+ python3 scripts/rpm-repository.py verify --target opensuse-tumbleweed-x86_64 \
+ --repository "$fixture_dir/$repository_stage" --public-key "$proof_dir/repository-key.asc" \
+ --fingerprint "$fingerprint" >"$proof_dir/repositories/${repository_stage}-verification.json"
+ cp -a "$fixture_dir/$repository_stage" "$proof_dir/repositories/$repository_stage"
+done
+cp "$fixture_dir/initial/packages/$baseline_package" "$proof_dir/packages/"
+cp "$fixture_dir/upgraded/packages/$upgrade_package" "$proof_dir/packages/"
+baseline_rpm_sha256="$(sha256sum "$proof_dir/packages/$baseline_package" | awk '{ print $1 }')"
+upgrade_rpm_sha256="$(sha256sum "$proof_dir/packages/$upgrade_package" | awk '{ print $1 }')"
+printf 'baseline\t%s\t%s\nupgraded\t%s\t%s\n' \
+ "$baseline_package" "$baseline_rpm_sha256" "$upgrade_package" "$upgrade_rpm_sha256" \
+ >"$proof_dir/signed-packages.tsv"
+
+fixture_rpmdb="/var/lib/loopwire-repository-proof-rpmdb"
+sudo test ! -e "$fixture_rpmdb"
+sudo install -d -m 0700 -o root -g root "$fixture_rpmdb"
+# Tumbleweed's enforcing SELinux policy permits RPM database writes only under
+# the system database label. The database remains isolated and is removed below.
+sudo chcon --reference=/usr/lib/sysimage/rpm "$fixture_rpmdb"
+sudo rpm --dbpath "$fixture_rpmdb" --initdb
+sudo rpm --dbpath "$fixture_rpmdb" --import "$proof_dir/repository-key.asc"
+sudo rpm --dbpath "$fixture_rpmdb" -Kv "$proof_dir/packages/$baseline_package" >"$proof_dir/packages/baseline-rpm-signature.txt"
+sudo rpm --dbpath "$fixture_rpmdb" -Kv "$proof_dir/packages/$upgrade_package" >"$proof_dir/packages/upgraded-rpm-signature.txt"
+
+# A guest-only CA exercises HTTPS trust without introducing a production credential.
+openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj '/CN=Loopwire disposable guest CA' \
+ -keyout "$fixture_dir/ca-key.pem" -out "$fixture_dir/ca.crt" \
+ -addext 'basicConstraints=critical,CA:TRUE' -addext 'keyUsage=critical,keyCertSign,cRLSign'
+openssl req -newkey rsa:2048 -nodes -subj '/CN=127.0.0.1' \
+ -keyout "$fixture_dir/tls-key.pem" -out "$fixture_dir/tls.csr"
+printf 'subjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n' \
+ >"$fixture_dir/tls.ext"
+openssl x509 -req -in "$fixture_dir/tls.csr" -CA "$fixture_dir/ca.crt" -CAkey "$fixture_dir/ca-key.pem" \
+ -CAcreateserial -days 1 -extfile "$fixture_dir/tls.ext" -out "$fixture_dir/tls.crt"
+cp "$fixture_dir/ca.crt" "$proof_dir/tls-ca.crt"
+cp "$fixture_dir/tls.crt" "$proof_dir/tls-server.crt"
+sudo install -m 0644 "$fixture_dir/ca.crt" /etc/pki/trust/anchors/loopwire-guest-fixture.crt
+sudo update-ca-certificates
+mkdir -p "$fixture_dir/www/opensuse/tumbleweed"
+ln -s "$fixture_dir/initial" "$fixture_dir/www/opensuse/tumbleweed/x86_64"
+cat >"$fixture_dir/https-server.py" <<'PY'
+import functools, http.server, ssl, sys
+class Handler(http.server.SimpleHTTPRequestHandler):
+ def do_GET(self):
+ if "If-Modified-Since" in self.headers:
+ del self.headers["If-Modified-Since"]
+ super().do_GET()
+class Server(http.server.ThreadingHTTPServer):
+ def shutdown_request(self, request):
+ request.settimeout(5)
+ try: request.unwrap()
+ except (OSError, ssl.SSLError): request.close()
+server = Server(("127.0.0.1", 8445), functools.partial(Handler, directory=sys.argv[1]))
+context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+context.load_cert_chain(sys.argv[2], sys.argv[3])
+server.socket = context.wrap_socket(server.socket, server_side=True)
+server.serve_forever()
+PY
+python3 "$fixture_dir/https-server.py" "$fixture_dir/www" "$fixture_dir/tls.crt" "$fixture_dir/tls-key.pem" \
+ >"$proof_dir/https-server.log" 2>&1 &
+server_pid=$!
+cleanup() { kill "$server_pid" 2>/dev/null || true; }
+trap cleanup EXIT
+for _attempt in $(seq 1 20); do
+ if curl --fail --silent --show-error "$base_url/keys/$fingerprint.asc" >"$proof_dir/https-key.asc"; then break; fi
+ sleep 1
+done
+cmp "$proof_dir/repository-key.asc" "$proof_dir/https-key.asc"
+
+verify_public_stage() {
+ local stage="$1"
+ python3 scripts/verify-opensuse-public.py --repository "$fixture_dir/$stage" \
+ --public-key "$proof_dir/repository-key.asc" --fingerprint "$fingerprint" \
+ --base-url "$base_url" --ca-file "$fixture_dir/ca.crt" \
+ | tee "$proof_dir/repositories/${stage}-public-verification.json"
+}
+
+verify_public_stage initial
+sudo bash scripts/setup-opensuse-repository.sh --base-url "$base_url" --fingerprint "$fingerprint" \
+ >"$proof_dir/bootstrap.log" 2>&1
+cat /etc/zypp/repos.d/loopwire.repo >"$proof_dir/loopwire.repo"
+cat "/etc/zypp/keys/loopwire-repository-$fingerprint.asc" >"$proof_dir/configured-repository-key.asc"
+cmp "$proof_dir/repository-key.asc" "$proof_dir/configured-repository-key.asc"
+sudo zypper --non-interactive --gpg-auto-import-keys refresh --force loopwire >"$proof_dir/bootstrap-refresh.log" 2>&1
+
+smoke_installed() {
+ local stage="$1" expected_version="$2" package_name="$3" stage_dir="$proof_dir/$1"
+ mkdir -p "$stage_dir"
+ rpm -q --qf '%{NAME}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{VENDOR}\n' loopwire >"$stage_dir/package-metadata.tsv"
+ [ "$(rpm -q --qf '%{VERSION}-%{RELEASE}' loopwire)" = "$expected_version" ]
+ [ "$(rpm -q --qf '%{VENDOR}' loopwire)" = '(none)' ]
+ zypper --no-refresh --xmlout search --installed-only --details --match-exact loopwire >"$stage_dir/zypper-search.xml"
+ zypper --no-refresh --xmlout search --details --repo loopwire --match-exact loopwire \
+ >"$stage_dir/zypper-repository-search.xml"
+ python3 - "$stage_dir/zypper-search.xml" "$stage_dir/zypper-repository-search.xml" \
+ "$proof_dir/$stage.log" "$expected_version" >"$stage_dir/zypper-origin.tsv" <<'PY'
+import pathlib, sys, xml.etree.ElementTree as ET
+installed_path, repository_path, log_path, version = sys.argv[1:]
+root = ET.parse(installed_path).getroot()
+items = [item for item in root.iter("solvable") if item.attrib.get("name") == "loopwire"]
+assert len(items) == 1
+item = items[0]
+assert item.attrib.get("status") == "installed" and item.attrib.get("edition") == version
+repository = "Loopwire for openSUSE Tumbleweed - x86_64"
+assert item.attrib.get("arch") == "x86_64"
+assert item.attrib.get("repository") in (repository, "(System Packages)")
+candidates = [item for item in ET.parse(repository_path).getroot().iter("solvable")
+ if item.attrib.get("name") == "loopwire" and item.attrib.get("edition") == version]
+assert len(candidates) == 1 and candidates[0].attrib.get("arch") == "x86_64"
+assert candidates[0].attrib.get("repository") == repository
+log = pathlib.Path(log_path).read_text()
+assert f"Retrieving: loopwire-{version}.x86_64 ({repository})" in log
+print("loopwire\t%s\tx86_64\t%s\t(none)" % (version, repository))
+PY
+ zypper --no-refresh info loopwire >"$stage_dir/zypper-info.txt"
+ rpm -ql loopwire | sort >"$stage_dir/package-files.txt"
+ while IFS= read -r installed_file; do
+ if [ -f "$installed_file" ]; then sha256sum "$installed_file"; fi
+ done <"$stage_dir/package-files.txt" >"$stage_dir/installed-files.sha256"
+ printf '%s %s\n' "$(sha256sum "$proof_dir/packages/$package_name" | awk '{ print $1 }')" "$package_name" \
+ >"$stage_dir/signed-package.sha256"
+ sudo rpm --dbpath "$fixture_rpmdb" -Kv "$proof_dir/packages/$package_name" >"$stage_dir/rpm-signature.txt"
+ loopwire --background --help >"$stage_dir/background-help.txt"
+ loopwire-dsp-provider --help >"$stage_dir/dsp-provider-help.txt"
+ loopwire-jack-ports --help >"$stage_dir/jack-provider-help.txt"
+ loopwire-detect-audio --pretty >"$stage_dir/detect-audio.json"
+ ldd /usr/lib/loopwire/loopwire-gui >"$stage_dir/gui-ldd.txt"
+ if grep -Fq 'not found' "$stage_dir/gui-ldd.txt"; then
+ echo 'Loopwire GUI has an unresolved shared-library dependency' >&2
+ exit 1
+ fi
+ local gui_status=0
+ # shellcheck disable=SC2016
+ timeout 35s bash -c '
+ stage_dir="$1"; app_pid=""
+ Xvfb :99 -screen 0 1280x720x24 -nolisten tcp >"$stage_dir/xvfb.log" 2>&1 &
+ xvfb_pid=$!
+ cleanup_gui() { [ -z "$app_pid" ] || kill "$app_pid" 2>/dev/null || true; kill "$xvfb_pid" 2>/dev/null || true; wait || true; }
+ trap cleanup_gui EXIT
+ sleep 1
+ DISPLAY=:99 GDK_BACKEND=x11 WEBKIT_DISABLE_DMABUF_RENDERER=1 \
+ /usr/lib/loopwire/loopwire-gui >"$stage_dir/gui-launch.log" 2>&1 &
+ app_pid=$!
+ for attempt in $(seq 1 20); do
+ kill -0 "$app_pid" 2>/dev/null || exit 1
+ if DISPLAY=:99 xdotool search --name "^(Loopwire|loopwire-gui)$" >"$stage_dir/gui-window-ids.txt" 2>/dev/null; then
+ while read -r window_id; do DISPLAY=:99 xdotool getwindowname "$window_id"; done \
+ <"$stage_dir/gui-window-ids.txt" >"$stage_dir/gui-window-names.txt"
+ exit 0
+ fi
+ sleep 1
+ done
+ exit 124
+ ' bash "$stage_dir" || gui_status=$?
+ printf '%s\n' "$gui_status" >"$stage_dir/gui-launch-status.txt"
+ [ "$gui_status" -eq 0 ] && [ -s "$stage_dir/gui-window-ids.txt" ]
+ if grep -Eiq 'error while loading shared libraries|panic|protocol error|missing acquire timeline' \
+ "$stage_dir/gui-launch.log"; then
+ echo 'Loopwire GUI logged a fatal launch error' >&2
+ exit 1
+ fi
+ printf '%s\t%s\tinstalled\n' "$stage" "$expected_version" >>"$proof_dir/lifecycle.tsv"
+}
+
+sudo zypper --non-interactive install --from loopwire --no-recommends "loopwire=$baseline_package_version" >"$proof_dir/install.log" 2>&1
+smoke_installed install "$baseline_package_version" "$baseline_package"
+sudo zypper --non-interactive install --from loopwire --force --no-allow-vendor-change --no-allow-arch-change \
+ --no-recommends "loopwire=$baseline_package_version" >"$proof_dir/reinstall.log" 2>&1
+smoke_installed reinstall "$baseline_package_version" "$baseline_package"
+ln -sfn "$fixture_dir/upgraded" "$fixture_dir/www/opensuse/tumbleweed/x86_64"
+verify_public_stage upgraded
+sudo zypper --non-interactive --gpg-auto-import-keys refresh --force loopwire >"$proof_dir/upgrade-refresh.log" 2>&1
+sudo zypper --non-interactive update --repo loopwire loopwire >"$proof_dir/upgrade.log" 2>&1
+smoke_installed upgrade "$upgrade_package_version" "$upgrade_package"
+ln -sfn "$fixture_dir/rolled-back" "$fixture_dir/www/opensuse/tumbleweed/x86_64"
+verify_public_stage rolled-back
+sudo zypper --non-interactive --gpg-auto-import-keys refresh --force loopwire >"$proof_dir/rollback-refresh.log" 2>&1
+sudo zypper --non-interactive install --from loopwire --oldpackage --force \
+ --no-allow-vendor-change --no-allow-arch-change --no-recommends \
+ "loopwire=$baseline_package_version" >"$proof_dir/rollback.log" 2>&1
+smoke_installed rollback "$baseline_package_version" "$baseline_package"
+sudo zypper --non-interactive remove loopwire >"$proof_dir/remove.log" 2>&1
+if rpm -q loopwire >/dev/null 2>&1; then
+ echo 'Loopwire package remains installed after removal' >&2
+ exit 1
+fi
+for removed_file in /usr/bin/loopwire /usr/bin/loopwire-dsp-provider /usr/bin/loopwire-jack-ports \
+ /usr/bin/loopwire-detect-audio /usr/lib/loopwire /usr/share/applications/loopwire.desktop \
+ /usr/share/icons/hicolor/scalable/apps/loopwire.svg; do
+ test ! -e "$removed_file"
+ printf '%s\tabsent\n' "$removed_file" >>"$proof_dir/removed-files.tsv"
+done
+printf 'remove\t%s\tabsent\n' "$baseline_package_version" >>"$proof_dir/lifecycle.tsv"
+sudo rm -rf -- "$fixture_rpmdb"
+test ! -e "$fixture_rpmdb"
+sudo bash scripts/setup-opensuse-repository.sh --remove >"$proof_dir/source-removal.log" 2>&1
+test ! -e /etc/zypp/repos.d/loopwire.repo
+test ! -e "/etc/zypp/keys/loopwire-repository-$fingerprint.asc"
+sudo zypper clean --all >"$proof_dir/source-removal-clean.log" 2>&1
+zypper repos --details >"$proof_dir/source-removal-repositories.txt"
+if grep -Eq '(^|[[:space:]|])loopwire([[:space:]|]|$)' "$proof_dir/source-removal-repositories.txt"; then
+ echo 'Loopwire repository remains configured after removal' >&2
+ exit 1
+fi
+
+snapshot="$(sed -n 's/^VERSION_ID="\{0,1\}\([^"[:space:]]*\)"\{0,1\}$/\1/p' /etc/os-release)"
+[[ "$snapshot" =~ ^[0-9]{8}$ ]]
+{
+ printf 'schema\tloopwire.opensuse-repository-vm-proof.v1\n'
+ printf 'target\t%s\n' "$target"
+ printf 'snapshot\t%s\n' "$snapshot"
+ printf 'git_head\t%s\n' "$git_head"
+ printf 'version\t%s\n' "$version"
+ printf 'upgrade_version\t%s\n' "$upgrade_version"
+ printf 'baseline_package_version\t%s\n' "$baseline_package_version"
+ printf 'upgrade_package_version\t%s\n' "$upgrade_package_version"
+ printf 'fingerprint\t%s\n' "$fingerprint"
+ printf 'base_url\t%s\n' "$base_url"
+ printf 'payload_kind\tpublic-release-baseline-with-synthetic-upgrade\n'
+ printf 'synthetic_upgrade\ttrue\n'
+ printf 'public_release_git_head\t%s\n' "$public_release_git_head"
+ printf 'baseline_source_sha256\t%s\n' "$baseline_source_sha256"
+ printf 'upgrade_source_sha256\t%s\n' "$upgrade_source_sha256"
+ printf 'baseline_rpm_sha256\t%s\n' "$baseline_rpm_sha256"
+ printf 'upgrade_rpm_sha256\t%s\n' "$upgrade_rpm_sha256"
+ printf 'verification_epoch\t%s\n' "$(date +%s)"
+} >"$proof_dir/summary.tsv"
+set +x
+echo "openSUSE repository lifecycle proof passed: $target snapshot $snapshot"
diff --git a/scripts/e2e-site-install.mjs b/scripts/e2e-site-install.mjs
index d1b827a..5d17d96 100644
--- a/scripts/e2e-site-install.mjs
+++ b/scripts/e2e-site-install.mjs
@@ -6,11 +6,13 @@ import { mkdirSync, readFileSync } from "node:fs";
import { createServer } from "node:http";
import { createRequire } from "node:module";
import { extname, resolve, sep } from "node:path";
+import { verifiedOpenSuseChannel } from "../apps/site/src/lib/rpmChannel.mjs";
const root = resolve("dist/site");
const guide = readFileSync("apps/docs/docs/guide/install.md", "utf8");
const platforms = ["automatic", "arch", "ubuntu", "debian", "fedora", "opensuse", "nix", "portable", "source"];
const automatic = "curl -fsSL https://loopwire.app/install.sh | bash";
+const opensuseChannel = verifiedOpenSuseChannel(JSON.parse(readFileSync("packaging/repositories/opensuse-channel.json", "utf8")));
const proofIndex = process.argv.indexOf("--screenshots");
const proofDir = proofIndex < 0 ? undefined : process.argv[proofIndex + 1];
assert.ok(proofIndex < 0 || proofDir, "--screenshots requires an output directory");
@@ -26,8 +28,9 @@ const server = createServer((request, response) => {
const path = decodeURIComponent(new URL(request.url, "http://localhost").pathname);
const file = resolve(root, `.${path.endsWith("/") ? `${path}index.html` : path}`);
if (!file.startsWith(`${root}${sep}`)) { response.writeHead(403).end(); return; }
+ const content = readFileSync(file);
response.writeHead(200, { "content-type": mime[extname(file)] ?? "application/octet-stream" });
- response.end(readFileSync(file));
+ response.end(content);
} catch { response.writeHead(404).end(); }
});
await new Promise((done) => server.listen(0, "127.0.0.1", done));
@@ -77,6 +80,35 @@ try {
assert.equal(paletteColors.size, platforms.length, "every tab selects a distinct background palette");
console.log("PASS: all nine panels, shell command syntax, guide parity and selected-command clipboard");
+ const opensuse = await context.newPage();
+ opensuse.on("pageerror", (error) => errors.push(error.message));
+ await opensuse.goto(url);
+ await opensuse.locator("#install-tab-opensuse").click();
+ const opensuseCommand = await opensuse.locator("#install-panel-opensuse code").textContent();
+ const opensuseLink = opensuse.locator("#install-panel-opensuse .install-notes a");
+ if (opensuseChannel) {
+ assert.equal(opensuseCommand, "sudo zypper install loopwire");
+ assert.equal(await opensuseLink.getAttribute("href"), "/docs/guide/opensuse-repository.html#one-time-setup");
+ } else {
+ assert.match(opensuseCommand, /openssl dgst -sha256 -verify/);
+ assert.match(opensuseCommand, /sudo zypper install --allow-unsigned-rpm \.\/loopwire-0\.1\.0-1\.x86_64\.rpm/);
+ }
+ await opensuse.goto(`${url}/docs/guide/opensuse-repository.html`);
+ const setupCommands = (await opensuse.locator("pre code").allTextContents())
+ .filter((command) => command.startsWith("sudo bash setup-opensuse-repository.sh --base-url"));
+ if (opensuseChannel) {
+ assert.equal(setupCommands.length, 1);
+ assert.equal(setupCommands[0], `sudo bash setup-opensuse-repository.sh --base-url '${opensuseChannel.baseUrl}' --fingerprint ${opensuseChannel.signingFingerprint}`);
+ execFileSync("bash", ["-n"], { input: setupCommands[0] });
+ assert.equal(await opensuse.getByText("Public channel pending", { exact: true }).count(), 0);
+ assert.ok(await opensuse.getByText("Verified public channel", { exact: true }).isVisible());
+ } else {
+ assert.deepEqual(setupCommands, []);
+ assert.ok(await opensuse.getByText("Public channel pending", { exact: true }).isVisible());
+ }
+ await opensuse.close();
+ console.log(`PASS: openSUSE ${opensuseChannel ? "verified" : "pending"} homepage and separate bootstrap guide`);
+
await page.locator("#install-tab-source").focus();
for (const [key, platform] of [["ArrowRight", "automatic"], ["ArrowLeft", "source"], ["Home", "automatic"], ["End", "source"]]) {
await page.keyboard.press(key);
@@ -223,6 +255,9 @@ try {
await proof.locator('#signal-field[data-motion="interactive"]').waitFor();
await proof.evaluate(() => document.fonts.ready);
await proof.screenshot({ path: resolve(proofDir, "desktop.png"), fullPage: true });
+ await proof.locator("#install-tab-opensuse").click();
+ await proof.waitForTimeout(1000);
+ await proof.screenshot({ path: resolve(proofDir, "opensuse-desktop.png"), fullPage: true });
await proof.locator("#install-tab-ubuntu").click();
await proof.waitForTimeout(1000);
await proof.screenshot({ path: resolve(proofDir, "ubuntu-palette.png"), fullPage: true });
@@ -237,6 +272,11 @@ try {
await mobile.goto(url);
await mobile.locator('#signal-field[data-motion="interactive"]').waitFor();
await mobile.evaluate(() => document.fonts.ready);
+ await mobile.locator("#install-tab-opensuse").click();
+ await mobile.waitForTimeout(1000);
+ await mobile.screenshot({ path: resolve(proofDir, "opensuse-mobile.png"), fullPage: true });
+ await mobile.locator("#install-tab-automatic").click();
+ await mobile.waitForTimeout(1000);
await mobile.screenshot({ path: resolve(proofDir, "mobile.png"), fullPage: true });
console.log(`Screenshot proofs written to ${proofDir}`);
}
diff --git a/scripts/native-package-vm.sh b/scripts/native-package-vm.sh
index e4ac9cf..e15c756 100755
--- a/scripts/native-package-vm.sh
+++ b/scripts/native-package-vm.sh
@@ -27,11 +27,14 @@ Usage:
native-package-vm.sh verify-apt --target ubuntu-24.04|debian-13 [--git-head COMMIT]
native-package-vm.sh run-fedora-repo --target fedora-44 --version VERSION --release-dir DIR
native-package-vm.sh verify-fedora-repo --target fedora-44 [--git-head COMMIT]
+ native-package-vm.sh run-opensuse-repo --target opensuse-tumbleweed --version VERSION --release-dir DIR
+ native-package-vm.sh verify-opensuse-repo --target opensuse-tumbleweed [--git-head COMMIT]
Environment:
LOOPWIRE_NATIVE_VM_ROOT Cache/run/evidence root (default: .vm/native-packages)
LOOPWIRE_APT_VM_ROOT APT run/evidence root (default: .vm/apt-repository)
LOOPWIRE_FEDORA_VM_ROOT Fedora repository run/evidence root (default: .vm/fedora-repository)
+ LOOPWIRE_OPENSUSE_VM_ROOT openSUSE repository run/evidence root (default: .vm/opensuse-repository)
LOOPWIRE_NATIVE_VM_TARGETS Target manifest override
LOOPWIRE_QEMU_IMAGE Docker QEMU tool image tag
@@ -262,6 +265,13 @@ run_target() {
release-assets.json; do
[ -f "$release_dir/$release_file" ] || fail "Fedora repository release artifact is missing: $release_file"
done
+ elif [ "$proof_kind" = "opensuse-repository" ]; then
+ for release_file in \
+ "loopwire-${version}-1.x86_64.rpm" \
+ SHA256SUMS.sig \
+ release-assets.json; do
+ [ -f "$release_dir/$release_file" ] || fail "openSUSE repository release artifact is missing: $release_file"
+ done
fi
require_host
require_committed_implementation
@@ -285,6 +295,9 @@ run_target() {
elif [ "$proof_kind" = "fedora-repository" ]; then
container="loopwire-fedora-repository-$id"
port=$((port + 20))
+ elif [ "$proof_kind" = "opensuse-repository" ]; then
+ container="loopwire-opensuse-repository-$id"
+ port=$((port + 30))
fi
key="$(ensure_ssh_key)"
public_key="$(cat "${key}.pub")"
@@ -301,6 +314,10 @@ run_target() {
cp "$release_dir/loopwire-${version}-1.fc44.x86_64.rpm" "$target_dir/kit/release/"
cp "$release_dir/SHA256SUMS.sig" "$target_dir/kit/release/"
cp "$release_dir/release-assets.json" "$target_dir/kit/release/"
+ elif [ "$proof_kind" = "opensuse-repository" ]; then
+ cp "$release_dir/loopwire-${version}-1.x86_64.rpm" "$target_dir/kit/release/"
+ cp "$release_dir/SHA256SUMS.sig" "$target_dir/kit/release/"
+ cp "$release_dir/release-assets.json" "$target_dir/kit/release/"
fi
write_cloud_init "$target_dir" "$public_key" "$id"
@@ -343,6 +360,9 @@ run_target() {
elif [ "$proof_kind" = "fedora-repository" ]; then
guest_script="packaging/vm/guest-fedora-repository-smoke.sh"
verifier="scripts/verify-fedora-repository-vm-proof.mjs"
+ elif [ "$proof_kind" = "opensuse-repository" ]; then
+ guest_script="packaging/vm/guest-opensuse-repository-smoke.sh"
+ verifier="scripts/verify-opensuse-repository-vm-proof.mjs"
fi
local guest_status=0
# Script paths are fixed and all client-expanded arguments are validated above.
@@ -364,8 +384,11 @@ run_target() {
actual_checksum "$(checksum_file "$algorithm" "$image_path")" \
firmware "$firmware" >"$evidence_dir/image.tsv"
[ "$guest_status" -eq 0 ] || fail "$id guest smoke failed ($guest_status); evidence: $evidence_dir"
- node "$verifier" \
- --target "$id" --evidence-dir "$evidence_dir" --git-head "$git_head"
+ local -a verifier_args=(--target "$id" --evidence-dir "$evidence_dir" --git-head "$git_head")
+ if [ "$proof_kind" = "opensuse-repository" ]; then
+ verifier_args+=(--target-manifest "$manifest")
+ fi
+ node "$verifier" "${verifier_args[@]}"
cleanup_active_vm
active_vm_container=""
active_vm_console=""
@@ -373,6 +396,7 @@ run_target() {
local proof_label="native package"
[ "$proof_kind" != "apt" ] || proof_label="APT repository lifecycle"
[ "$proof_kind" != "fedora-repository" ] || proof_label="Fedora repository lifecycle"
+ [ "$proof_kind" != "opensuse-repository" ] || proof_label="openSUSE repository lifecycle"
echo "Verified $proof_label in matching KVM guest: $id"
echo "Evidence: $evidence_dir"
}
@@ -384,8 +408,13 @@ verify_target() {
local verifier="scripts/verify-native-package-vm-proof.mjs"
[ "$proof_kind" != "apt" ] || verifier="scripts/verify-apt-repository-vm-proof.mjs"
[ "$proof_kind" != "fedora-repository" ] || verifier="scripts/verify-fedora-repository-vm-proof.mjs"
- node "$verifier" \
- --target "$selected" --evidence-dir "$vm_root/evidence/$selected/$git_head" --git-head "$git_head"
+ [ "$proof_kind" != "opensuse-repository" ] || verifier="scripts/verify-opensuse-repository-vm-proof.mjs"
+ local -a verifier_args=(--target "$selected" --evidence-dir "$vm_root/evidence/$selected/$git_head"
+ --git-head "$git_head")
+ if [ "$proof_kind" = "opensuse-repository" ]; then
+ verifier_args+=(--target-manifest "$manifest")
+ fi
+ node "$verifier" "${verifier_args[@]}"
}
[ -n "$root" ] || fail "run from inside the Loopwire git repository"
@@ -429,6 +458,13 @@ case "$command" in
[ "$(realpath -m "$vm_root")" != "$(realpath -m "$image_root")" ] ||
fail "Fedora repository VM state must use a different root from native-package state"
;;
+ run-opensuse-repo | verify-opensuse-repo)
+ [ "$selected" = "opensuse-tumbleweed" ] || fail "$command requires the openSUSE Tumbleweed target"
+ proof_kind="opensuse-repository"
+ vm_root="${LOOPWIRE_OPENSUSE_VM_ROOT:-.vm/opensuse-repository}"
+ [ "$(realpath -m "$vm_root")" != "$(realpath -m "$image_root")" ] ||
+ fail "openSUSE repository VM state must use a different root from native-package state"
+ ;;
esac
case "$command" in
@@ -447,7 +483,7 @@ case "$command" in
require_host
while read -r id; do download_target "$id"; done < <(target_ids)
;;
- run | run-apt | run-fedora-repo)
+ run | run-apt | run-fedora-repo | run-opensuse-repo)
[ -n "$selected" ] || fail "run requires --target"
[ -n "$version" ] || fail "run requires --version"
[ -n "$release_dir" ] || fail "run requires --release-dir"
@@ -458,7 +494,7 @@ case "$command" in
[ -n "$release_dir" ] || fail "run-all requires --release-dir"
while read -r id; do run_target "$id" "$version" "$release_dir"; done < <(target_ids)
;;
- verify | verify-apt | verify-fedora-repo)
+ verify | verify-apt | verify-fedora-repo | verify-opensuse-repo)
[ -n "$selected" ] || fail "verify requires --target"
verify_target "$selected" "$git_head"
;;
diff --git a/scripts/publish-opensuse-workflow.sh b/scripts/publish-opensuse-workflow.sh
new file mode 100755
index 0000000..33b9964
--- /dev/null
+++ b/scripts/publish-opensuse-workflow.sh
@@ -0,0 +1,106 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+target="opensuse-tumbleweed-x86_64"
+fail() { printf 'publish-opensuse-workflow: %s\n' "$*" >&2; exit 1; }
+for name in OPENSUSE_REPOSITORY_URL OPENSUSE_REPOSITORY_HOST OPENSUSE_REPOSITORY_ROOT OPENSUSE_SIGNING_FINGERPRINT \
+ OPENSUSE_SSH_PRIVATE_KEY OPENSUSE_SSH_KNOWN_HOSTS OPENSUSE_SIGNING_KEY RUNNER_TEMP GITHUB_REPOSITORY \
+ GITHUB_SERVER_URL GITHUB_RUN_ID; do
+ [ -n "${!name:-}" ] || fail "missing configuration: $name"
+done
+operation="${OPERATION:-refresh}"
+case "$operation" in
+ publish) [[ "${RELEASE_TAG:-}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "publish requires a stable vX.Y.Z release tag" ;;
+ refresh) ;;
+ rollback) [[ "${ROLLBACK_REVISION:-}" =~ ^[a-f0-9]{64}$ ]] || fail "rollback requires a retained revision SHA-256" ;;
+ *) fail "unknown operation" ;;
+esac
+[[ "$OPENSUSE_SIGNING_FINGERPRINT" =~ ^[A-F0-9]{40}$ ]] || fail "signing fingerprint must be complete uppercase hexadecimal"
+[[ "${OPENSUSE_SSH_PORT:-22}" =~ ^[0-9]+$ ]] || fail "SSH port must be numeric"
+python3 - "$OPENSUSE_REPOSITORY_URL" <<'PY'
+import runpy, sys
+validate = runpy.run_path('scripts/verify-opensuse-public.py')['validate_base_url']
+try:
+ validate(sys.argv[1])
+except ValueError as error:
+ sys.exit(f'publish-opensuse-workflow: {error}')
+PY
+
+work="$(mktemp -d "$RUNNER_TEMP/loopwire-opensuse.XXXXXX")"
+cleanup() {
+ gpgconf --homedir "$work/gnupg" --kill all >/dev/null 2>&1 || true
+ rm -rf -- "$work"
+}
+trap cleanup EXIT
+umask 077
+mkdir "$work/gnupg"
+printf '%s\n' "$OPENSUSE_SSH_PRIVATE_KEY" >"$work/ssh-key"
+printf '%s\n' "$OPENSUSE_SSH_KNOWN_HOSTS" >"$work/known-hosts"
+printf '%s\n' "$OPENSUSE_SIGNING_KEY" >"$work/signing-key.asc"
+gpg --no-options --batch --homedir "$work/gnupg" --import "$work/signing-key.asc"
+gpg --no-options --batch --homedir "$work/gnupg" --armor --export "$OPENSUSE_SIGNING_FINGERPRINT" >"$work/public-key.asc"
+[ -s "$work/public-key.asc" ] || fail "configured key does not match the expected fingerprint"
+sign_args=(--signing-key "$OPENSUSE_SIGNING_FINGERPRINT" --gnupg-home "$work/gnupg" --valid-for-days 30)
+if [ -n "${OPENSUSE_SIGNING_PASSPHRASE:-}" ]; then
+ printf '%s' "$OPENSUSE_SIGNING_PASSPHRASE" >"$work/passphrase"
+ sign_args+=(--passphrase-file "$work/passphrase")
+fi
+unset OPENSUSE_SSH_PRIVATE_KEY OPENSUSE_SSH_KNOWN_HOSTS OPENSUSE_SIGNING_KEY OPENSUSE_SIGNING_PASSPHRASE
+transport=(--target "$target" --root "$OPENSUSE_REPOSITORY_ROOT" --ssh "$OPENSUSE_REPOSITORY_HOST"
+ --ssh-port "${OPENSUSE_SSH_PORT:-22}" --identity-file "$work/ssh-key" --known-hosts "$work/known-hosts"
+ --public-key "$work/public-key.asc" --fingerprint "$OPENSUSE_SIGNING_FINGERPRINT")
+
+set +e
+python3 scripts/publish-rpm-repository.py fetch "${transport[@]}" --output "$work/current"
+fetch_status=$?
+set -e
+previous_args=()
+if [ "$fetch_status" -eq 0 ]; then
+ expected="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["revision"])' \
+ "$work/current/repository-manifest.json")"
+ previous_args=(--previous "$work/current")
+elif [ "$fetch_status" -eq 3 ] && [ "$operation" = publish ]; then
+ expected=empty
+else
+ fail "could not load the existing openSUSE repository (status $fetch_status); no publication attempted"
+fi
+
+case "$operation" in
+ publish)
+ gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease,tagName >"$work/release.json"
+ python3 - "$work/release.json" "$RELEASE_TAG" <<'PY'
+import json, sys
+release = json.load(open(sys.argv[1]))
+if release['isDraft'] or release['isPrerelease'] or release['tagName'] != sys.argv[2]:
+ sys.exit('openSUSE publication requires the requested published stable release')
+PY
+ mkdir "$work/release"
+ gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --dir "$work/release"
+ release_commit="$(git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}")"
+ bash scripts/verify-release-signature.sh --release-dir "$work/release" --public-key packaging/release-signing-public.pem
+ node scripts/release-asset-manifest.mjs verify --release-dir "$work/release" --tag "$RELEASE_TAG" \
+ --git-head "$release_commit" --require-checksum --require-evidence
+ python3 scripts/rpm-repository.py build --target "$target" --release-dir "$work/release" \
+ --version "${RELEASE_TAG#v}" --output "$work/candidate" "${sign_args[@]}" "${previous_args[@]}"
+ ;;
+ refresh)
+ python3 scripts/rpm-repository.py rollback --target "$target" --repository "$work/current" \
+ --output "$work/candidate" "${sign_args[@]}"
+ ;;
+ rollback)
+ python3 scripts/publish-rpm-repository.py fetch "${transport[@]}" --revision "$ROLLBACK_REVISION" \
+ --output "$work/rollback"
+ python3 scripts/rpm-repository.py rollback --target "$target" --repository "$work/rollback" \
+ --output "$work/candidate" "${sign_args[@]}"
+ ;;
+esac
+
+mkdir -p dist/opensuse-publication
+python3 scripts/publish-rpm-repository.py publish "${transport[@]}" --repository "$work/candidate" \
+ --expected-revision "$expected" >dist/opensuse-publication/publication.json
+python3 scripts/verify-opensuse-public.py --repository "$work/candidate" --public-key "$work/public-key.asc" \
+ --fingerprint "$OPENSUSE_SIGNING_FINGERPRINT" --base-url "$OPENSUSE_REPOSITORY_URL" \
+ --proof-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
+ --output dist/opensuse-publication/opensuse-channel.json
+cp "$work/candidate/repository-manifest.json" dist/opensuse-publication/repository-manifest.json
+printf 'openSUSE repository published and verified; activation record is in the workflow artifact.\n'
diff --git a/scripts/publish-rpm-repository.py b/scripts/publish-rpm-repository.py
index f727b1e..d9f1220 100644
--- a/scripts/publish-rpm-repository.py
+++ b/scripts/publish-rpm-repository.py
@@ -1,9 +1,12 @@
#!/usr/bin/env python3
-"""Publish a verified Fedora RPM repository to a POSIX origin.
+"""Publish verified Fedora and openSUSE RPM repositories to a POSIX origin.
-ROOT/public is the HTTP document root. The supported DNF base URL is
-ROOT/public/fedora/44/x86_64. ROOT/snapshots and ROOT/state are private.
-Package, key, and checksum-named repodata URLs are immutable and retained.
+ROOT/public is the HTTP document root. Supported base URLs are
+ROOT/public/fedora/44/x86_64 and ROOT/public/opensuse/tumbleweed/x86_64.
+Fedora keeps its existing private ROOT/state and ROOT/snapshots paths. openSUSE
+uses ROOT/channels/opensuse-tumbleweed-x86_64/{state,snapshots} so locks, journals,
+revisions, and rollback inputs remain independent. Package, key, and
+checksum-named repodata URLs are immutable and retained within each namespace.
RPM metadata uses a fail-closed commit protocol: repomd.xml.asc is replaced
first and repomd.xml is replaced atomically last. A client crossing that
@@ -39,15 +42,43 @@
MANIFEST = "repository-manifest.json"
SCHEMA = "loopwire.rpm-repository.v1"
-TARGET = {"distribution": "fedora", "release": "44", "architecture": "x86_64"}
-PUBLIC_PREFIX = Path("fedora/44/x86_64")
+DEFAULT_TARGET = "fedora-44-x86_64"
+TARGETS = {
+ DEFAULT_TARGET: {
+ "manifest": {"distribution": "fedora", "release": "44", "architecture": "x86_64"},
+ "publicPrefix": Path("fedora/44/x86_64"),
+ "packageRelease": "1.fc44",
+ "sourceRevision": False,
+ "packagePattern": re.compile(
+ r"packages/loopwire-{version}-1\.fc44\.x86_64\.rpm\Z"
+ ),
+ "label": "Fedora 44 x86_64",
+ "client": "DNF",
+ },
+ "opensuse-tumbleweed-x86_64": {
+ "manifest": {
+ "distribution": "opensuse", "release": "tumbleweed", "architecture": "x86_64",
+ },
+ "publicPrefix": Path("opensuse/tumbleweed/x86_64"),
+ "packageRelease": "1",
+ "sourceRevision": True,
+ "packagePattern": re.compile(
+ r"packages/loopwire-{version}-1\.x86_64\.rpm\Z"
+ ),
+ "label": "openSUSE Tumbleweed x86_64",
+ "client": "Zypper/libzypp",
+ },
+}
REVISION = re.compile(r"[0-9a-f]{64}\Z")
FINGERPRINT = re.compile(r"(?:[A-F0-9]{40}|[A-F0-9]{64})\Z")
VERSION = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:\+[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?"
-PACKAGE_PATH = re.compile(rf"packages/loopwire-{VERSION}-1\.fc44\.x86_64\.rpm\Z")
REPODATA_PATH = re.compile(
r"repodata/[0-9a-f]{64}-(?:primary|filelists|other)\.xml\.gz\Z"
)
+# Compatibility aliases for existing Fedora callers and tests.
+TARGET = TARGETS[DEFAULT_TARGET]["manifest"]
+PUBLIC_PREFIX = TARGETS[DEFAULT_TARGET]["publicPrefix"]
+PACKAGE_PATH = re.compile(rf"packages/loopwire-{VERSION}-1\.fc44\.x86_64\.rpm\Z")
MANIFEST_FIELDS = {
"schema", "schemaVersion", "revision", "signingFingerprint",
"createdAt", "validUntil", "target", "packages", "files",
@@ -72,6 +103,14 @@ def require(condition, message):
raise PublicationError(message)
+def target_config(target=DEFAULT_TARGET):
+ require(isinstance(target, str) and target in TARGETS,
+ "unsupported RPM repository target")
+ config = TARGETS[target]
+ pattern = config["packagePattern"].pattern.format(version=VERSION)
+ return target, config, re.compile(pattern)
+
+
def canonical(value):
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
@@ -104,10 +143,11 @@ def safe_path(value):
return path
-def classify(path):
+def classify(path, target=DEFAULT_TARGET):
"""Derive URL mutability from the protocol, never from manifest input."""
safe_path(path)
- if PACKAGE_PATH.fullmatch(path):
+ _target, _config, package_path = target_config(target)
+ if package_path.fullmatch(path):
return "immutable"
if re.fullmatch(r"keys/(?:[A-F0-9]{40}|[A-F0-9]{64})\.asc", path):
return "immutable"
@@ -115,7 +155,7 @@ def classify(path):
return "immutable"
if path in ("repodata/repomd.xml", "repodata/repomd.xml.asc"):
return "metadata"
- raise PublicationError("inventory contains a path outside the Fedora RPM protocol")
+ raise PublicationError("inventory contains a path outside the selected RPM protocol")
def plain_path(path, directory=False, missing=False):
@@ -161,7 +201,8 @@ def read_json(path):
raise PublicationError("invalid repository JSON") from error
-def inventory(root, fingerprint):
+def inventory(root, fingerprint, target=DEFAULT_TARGET):
+ target, config, package_path = target_config(target)
root = plain_path(root, directory=True)
actual = tree_files(root)
require(MANIFEST in actual, "candidate is missing its manifest")
@@ -178,8 +219,8 @@ def inventory(root, fingerprint):
require(isinstance(fingerprint, str) and FINGERPRINT.fullmatch(fingerprint)
and manifest.get("signingFingerprint") == fingerprint,
"candidate signing fingerprint differs")
- require(manifest.get("target") == TARGET,
- "candidate must target Fedora 44 x86_64")
+ require(manifest.get("target") == config["manifest"],
+ f"candidate must target {config['label']}")
require(type(manifest.get("createdAt")) is int and type(manifest.get("validUntil")) is int
and manifest["validUntil"] > manifest["createdAt"],
"candidate validity interval is invalid")
@@ -193,16 +234,16 @@ def inventory(root, fingerprint):
require(isinstance(entry, dict), "invalid candidate file entry")
require(set(entry) == FILE_FIELDS, "invalid candidate file fields")
path = entry.get("path")
- kind = classify(path)
+ kind = classify(path, target)
require(path not in expected and entry.get("kind") == kind,
"duplicate file or incorrect inventory kind")
require(type(entry.get("size")) is int and entry["size"] >= 0,
"invalid inventory file size")
require(isinstance(entry.get("sha256"), str) and REVISION.fullmatch(entry["sha256"]),
"invalid inventory digest")
- target = root / path
- require(path in actual and target.stat().st_size == entry["size"]
- and digest(target) == entry["sha256"],
+ candidate_file = root / path
+ require(path in actual and candidate_file.stat().st_size == entry["size"]
+ and digest(candidate_file) == entry["sha256"],
"candidate file checksum or size differs")
expected.add(path)
indexed[path] = entry
@@ -214,21 +255,24 @@ def inventory(root, fingerprint):
"candidate is missing signed repository metadata")
require(f"keys/{fingerprint}.asc" in indexed,
"candidate is missing its pinned public key")
- require(all(isinstance(item, dict) and set(item) == PACKAGE_FIELDS
+ expected_package_fields = (PACKAGE_FIELDS | {"sourceRevision"}
+ if config["sourceRevision"] else PACKAGE_FIELDS)
+ require(all(isinstance(item, dict) and set(item) == expected_package_fields
for item in manifest["packages"]), "invalid package record fields")
package_paths = {item.get("path") for item in manifest["packages"]}
- require(package_paths and all(PACKAGE_PATH.fullmatch(path or "") for path in package_paths),
+ require(package_paths and all(package_path.fullmatch(path or "") for path in package_paths),
"candidate has an invalid package record")
require(len(package_paths) == len(manifest["packages"]),
"candidate has duplicate package records")
- require(package_paths == {path for path in indexed if PACKAGE_PATH.fullmatch(path)},
+ require(package_paths == {path for path in indexed if package_path.fullmatch(path)},
"package records and file inventory differ")
for package in manifest["packages"]:
- require(package["name"] == "loopwire" and package["release"] == "1.fc44"
+ require(package["name"] == "loopwire"
+ and package["release"] == config["packageRelease"]
and package["architecture"] == "x86_64"
and re.fullmatch(VERSION, package["version"])
and package["path"] == (
- f"packages/loopwire-{package['version']}-1.fc44.x86_64.rpm"
+ f"packages/loopwire-{package['version']}-{config['packageRelease']}.x86_64.rpm"
), "candidate package identity is invalid")
require(isinstance(package["sourceReleaseSha256"], str)
and REVISION.fullmatch(package["sourceReleaseSha256"])
@@ -236,6 +280,10 @@ def inventory(root, fingerprint):
and REVISION.fullmatch(package["distributedSha256"])
and type(package["size"]) is int and package["size"] >= 0,
"candidate package hash or size is invalid")
+ if config["sourceRevision"]:
+ require(isinstance(package["sourceRevision"], str)
+ and re.fullmatch(r"[0-9a-f]{40}", package["sourceRevision"]),
+ "candidate source revision is invalid")
entry = indexed[package["path"]]
require(entry["sha256"] == package["distributedSha256"]
and entry["size"] == package["size"],
@@ -243,12 +291,14 @@ def inventory(root, fingerprint):
return manifest
-def verify_signed(root, key, fingerprint, historical=False):
- manifest = inventory(root, fingerprint)
+def verify_signed(root, key, fingerprint, historical=False, target=DEFAULT_TARGET):
+ manifest = inventory(root, fingerprint, target)
verifier = Path(__file__).resolve().with_name("rpm-repository.py")
require(verifier.is_file(), "rpm-repository.py verifier is missing")
command = [sys.executable, str(verifier), "verify", "--repository", str(root),
"--public-key", str(key), "--fingerprint", fingerprint]
+ if target != DEFAULT_TARGET:
+ command += ["--target", target]
if historical:
command += ["--now", str(manifest["createdAt"])]
result = subprocess.run(command, capture_output=True, text=True, check=False)
@@ -308,20 +358,31 @@ def root_path(value):
return plain_path(path, directory=True, missing=True)
-def public_channel(root):
- return root / "public" / PUBLIC_PREFIX
+def public_channel(root, target=DEFAULT_TARGET):
+ _target, config, _package_path = target_config(target)
+ return root / "public" / config["publicPrefix"]
+
+
+def private_channel(root, target=DEFAULT_TARGET):
+ target, _config, _package_path = target_config(target)
+ return root if target == DEFAULT_TARGET else root / "channels" / target
@contextlib.contextmanager
-def locked(root, create=False):
+def locked(root, create=False, target=DEFAULT_TARGET):
+ target, _config, _package_path = target_config(target)
+ private = private_channel(root, target)
if create:
make_directory(root)
- if not root.exists():
+ if target != DEFAULT_TARGET:
+ make_directory(private.parent, mode=0o700)
+ make_directory(private, mode=0o700)
+ if not root.exists() or not private.exists():
raise EmptyRepository("repository has no committed snapshot")
- lock = root / ".publish.lock"
+ lock = private / ".publish.lock"
plain_path(lock, missing=True)
if not create and not lock.exists():
- require(not (root / "state").exists() and not public_channel(root).exists(),
+ require(not (private / "state").exists() and not public_channel(root, target).exists(),
"repository state exists without its publication lock")
raise EmptyRepository("repository has no committed snapshot")
flags = os.O_NOFOLLOW | (os.O_RDWR | os.O_CREAT if create else os.O_RDONLY)
@@ -340,8 +401,8 @@ def locked(root, create=False):
os.close(descriptor)
-def state(root, name):
- path = root / "state" / f"{name}.json"
+def state(root, name, target=DEFAULT_TARGET):
+ path = private_channel(root, target) / "state" / f"{name}.json"
plain_path(path, missing=True)
if not path.exists():
return None
@@ -366,18 +427,18 @@ def _checkpoint(label):
"""No-op hook for process-interruption tests; never environment-controlled."""
-def check_public(root, manifest, immutable_only=False):
- channel = public_channel(root)
+def check_public(root, manifest, immutable_only=False, target=DEFAULT_TARGET):
+ channel = public_channel(root, target)
for entry in manifest["files"]:
if immutable_only and entry["kind"] != "immutable":
continue
- target = channel / entry["path"]
- plain_path(target, missing=True)
- if target.exists():
- info = target.stat()
+ public_file = channel / entry["path"]
+ plain_path(public_file, missing=True)
+ if public_file.exists():
+ info = public_file.stat()
require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1,
"public target is not a standalone regular file")
- require(info.st_size == entry["size"] and digest(target) == entry["sha256"],
+ require(info.st_size == entry["size"] and digest(public_file) == entry["sha256"],
"immutable URL collision" if immutable_only
else "committed public repository has drifted")
elif not immutable_only:
@@ -390,13 +451,13 @@ def check_public(root, manifest, immutable_only=False):
"committed public repository manifest has drifted")
-def save_snapshot(root, repository, manifest):
- snapshots = root / "snapshots"
+def save_snapshot(root, repository, manifest, target=DEFAULT_TARGET):
+ snapshots = private_channel(root, target) / "snapshots"
make_directory(snapshots, mode=0o700)
snapshot = snapshots / manifest["revision"]
plain_path(snapshot, directory=True, missing=True)
if snapshot.exists():
- require(inventory(snapshot, manifest["signingFingerprint"]) == manifest,
+ require(inventory(snapshot, manifest["signingFingerprint"], target) == manifest,
"retained snapshot differs from candidate")
return snapshot
temporary = Path(tempfile.mkdtemp(prefix=".staging-", dir=snapshots))
@@ -407,7 +468,7 @@ def save_snapshot(root, repository, manifest):
mode=0o600, directory_mode=0o700)
atomic_write(temporary / MANIFEST, source=repository / MANIFEST,
mode=0o600, directory_mode=0o700)
- inventory(temporary, manifest["signingFingerprint"])
+ inventory(temporary, manifest["signingFingerprint"], target)
fsync_directory(temporary)
os.replace(temporary, snapshot)
fsync_directory(snapshots)
@@ -417,20 +478,21 @@ def save_snapshot(root, repository, manifest):
return snapshot
-def promote(root, snapshot, manifest):
+def promote(root, snapshot, manifest, target=DEFAULT_TARGET):
"""Publish immutable data, signature, then atomic repomd.xml commit."""
- channel = public_channel(root)
+ private = private_channel(root, target)
+ channel = public_channel(root, target)
make_directory(channel)
devices = {path.stat().st_dev for path in
- (root, channel, root / "state", root / "snapshots")}
+ (root, channel, private / "state", private / "snapshots")}
require(len(devices) == 1,
"origin public, snapshot, and state paths must share one filesystem")
- check_public(root, manifest, immutable_only=True)
+ check_public(root, manifest, immutable_only=True, target=target)
for entry in manifest["files"]:
if entry["kind"] == "immutable":
- target = channel / entry["path"]
- if not target.exists():
- atomic_write(target, source=snapshot / entry["path"])
+ public_file = channel / entry["path"]
+ if not public_file.exists():
+ atomic_write(public_file, source=snapshot / entry["path"])
_checkpoint("immutable")
signature = "repodata/repomd.xml.asc"
atomic_write(channel / signature, source=snapshot / signature)
@@ -439,22 +501,25 @@ def promote(root, snapshot, manifest):
atomic_write(channel / metadata, source=snapshot / metadata)
_checkpoint("committed")
atomic_write(channel / MANIFEST, source=snapshot / MANIFEST)
- check_public(root, manifest)
+ check_public(root, manifest, target=target)
_checkpoint("manifest")
- atomic_write(root / "state" / "current.json",
+ atomic_write(private / "state" / "current.json",
data=canonical({"revision": manifest["revision"]}) + b"\n", mode=0o600)
_checkpoint("current")
- (root / "state" / "pending.json").unlink()
- fsync_directory(root / "state")
+ (private / "state" / "pending.json").unlink()
+ fsync_directory(private / "state")
+ _target, config, _package_path = target_config(target)
return {"status": "published", "revision": manifest["revision"],
- "target": TARGET.copy()}
+ "target": config["manifest"].copy()}
-def publish_at(root, repository, fingerprint, expected):
- manifest = inventory(repository, fingerprint)
- with locked(root, create=True):
- current = state(root, "current")
- pending = state(root, "pending")
+def publish_at(root, repository, fingerprint, expected, target=DEFAULT_TARGET):
+ target, config, _package_path = target_config(target)
+ private = private_channel(root, target)
+ manifest = inventory(repository, fingerprint, target)
+ with locked(root, create=True, target=target):
+ current = state(root, "current", target)
+ pending = state(root, "pending", target)
revision = current["revision"] if current else "empty"
if pending:
require(pending["revision"] == manifest["revision"],
@@ -463,48 +528,51 @@ def publish_at(root, repository, fingerprint, expected):
"expected revision differs from interrupted publication")
require(revision in (pending["previousRevision"], pending["revision"]),
"current revision conflicts with pending journal")
- snapshot = root / "snapshots" / pending["revision"]
- require(inventory(snapshot, fingerprint) == manifest,
+ snapshot = private / "snapshots" / pending["revision"]
+ require(inventory(snapshot, fingerprint, target) == manifest,
"pending snapshot differs from candidate")
- return promote(root, snapshot, manifest)
+ return promote(root, snapshot, manifest, target)
if revision == manifest["revision"]:
- check_public(root, manifest)
+ check_public(root, manifest, target=target)
return {"status": "unchanged", "revision": revision,
- "target": TARGET.copy()}
+ "target": config["manifest"].copy()}
require(expected == revision,
"expected revision differs from current publication (compare-and-swap failed)")
if current is None:
- channel = public_channel(root)
+ channel = public_channel(root, target)
plain_path(channel, directory=True, missing=True)
require(not channel.exists() or not any(channel.iterdir()),
- "refusing to adopt an unmanaged public Fedora repository")
- check_public(root, manifest, immutable_only=True)
- snapshot = save_snapshot(root, repository, manifest)
- make_directory(root / "state", mode=0o700)
- atomic_write(root / "state" / "pending.json", data=canonical({
+ "refusing to adopt an unmanaged public RPM repository")
+ check_public(root, manifest, immutable_only=True, target=target)
+ snapshot = save_snapshot(root, repository, manifest, target)
+ make_directory(private / "state", mode=0o700)
+ atomic_write(private / "state" / "pending.json", data=canonical({
"revision": manifest["revision"], "previousRevision": revision,
}) + b"\n", mode=0o600)
_checkpoint("journal")
- return promote(root, snapshot, manifest)
+ return promote(root, snapshot, manifest, target)
-def recover_at(root, fingerprint, revision):
- with locked(root, create=True):
- pending = state(root, "pending")
+def recover_at(root, fingerprint, revision, target=DEFAULT_TARGET):
+ private = private_channel(root, target)
+ with locked(root, create=True, target=target):
+ pending = state(root, "pending", target)
require(pending is not None and pending["revision"] == revision,
"pending publication changed; fetch and verify it again before recovery")
- current = state(root, "current")
+ current = state(root, "current", target)
require((current["revision"] if current else "empty")
in (pending.get("previousRevision"), revision),
"current revision conflicts with pending journal")
- snapshot = root / "snapshots" / revision
- return promote(root, snapshot, inventory(snapshot, fingerprint))
+ snapshot = private / "snapshots" / revision
+ return promote(root, snapshot, inventory(snapshot, fingerprint, target), target)
@contextlib.contextmanager
-def selected_snapshot(root, fingerprint, revision=None, pending_only=False):
- with locked(root):
- pending = state(root, "pending")
+def selected_snapshot(root, fingerprint, revision=None, pending_only=False,
+ target=DEFAULT_TARGET):
+ private = private_channel(root, target)
+ with locked(root, target=target):
+ pending = state(root, "pending", target)
if pending_only:
if not pending:
raise EmptyRepository("repository has no pending publication")
@@ -513,12 +581,12 @@ def selected_snapshot(root, fingerprint, revision=None, pending_only=False):
require(pending is None,
"interrupted publication pending; recover before fetching snapshots")
if revision is None:
- current = state(root, "current")
+ current = state(root, "current", target)
if not current:
raise EmptyRepository("repository has no committed snapshot")
revision = current["revision"]
- snapshot = root / "snapshots" / revision
- manifest = inventory(snapshot, fingerprint)
+ snapshot = private / "snapshots" / revision
+ manifest = inventory(snapshot, fingerprint, target)
require(manifest["revision"] == revision,
"snapshot does not match selected revision")
yield snapshot, manifest
@@ -530,20 +598,20 @@ def write_archive(repository, output):
archive.add(repository / relative, arcname=relative, recursive=False)
-def read_archive(source, output):
+def read_archive(source, output, target=DEFAULT_TARGET):
seen = set()
with tarfile.open(fileobj=source, mode="r|*") as archive:
for member in archive:
safe_path(member.name)
- require(member.name == MANIFEST or classify(member.name),
+ require(member.name == MANIFEST or classify(member.name, target),
"unexpected archive path")
require(member.isfile() and not member.issym() and not member.islnk()
and member.name not in seen,
"archive contains a link, special file, or duplicate")
seen.add(member.name)
- target = output / member.name
- make_directory(target.parent)
- with archive.extractfile(member) as incoming, target.open("xb") as destination:
+ destination_file = output / member.name
+ make_directory(destination_file.parent)
+ with archive.extractfile(member) as incoming, destination_file.open("xb") as destination:
shutil.copyfileobj(incoming, destination, 1024 * 1024)
@@ -594,7 +662,7 @@ def remote_call(args, request, repository=None, output=None):
"SSH repository operation failed; check pinned host key, identity, connectivity, remote Python, and publisher state")
outgoing.seek(0)
if output:
- read_archive(outgoing, output)
+ read_archive(outgoing, output, request.get("target", DEFAULT_TARGET))
return None
try:
return json.load(outgoing)
@@ -604,6 +672,9 @@ def remote_call(args, request, repository=None, output=None):
def serve(request):
root = root_path(request["root"])
+ target, _config, _package_path = target_config(
+ request.get("target", DEFAULT_TARGET)
+ )
fingerprint = request["fingerprint"]
require(FINGERPRINT.fullmatch(fingerprint), "invalid signing fingerprint")
action = request["action"]
@@ -612,35 +683,38 @@ def serve(request):
"invalid expected revision")
with tempfile.TemporaryDirectory(prefix="loopwire-rpm-upload-") as directory:
repository = Path(directory)
- read_archive(sys.stdin.buffer, repository)
- return publish_at(root, repository, fingerprint, request["expected"])
+ read_archive(sys.stdin.buffer, repository, target)
+ return publish_at(root, repository, fingerprint, request["expected"], target)
if action == "recover":
require(REVISION.fullmatch(request["revision"]), "invalid recovery revision")
- return recover_at(root, fingerprint, request["revision"])
+ return recover_at(root, fingerprint, request["revision"], target)
require(action in ("fetch", "fetch-pending"), "unknown remote operation")
revision = request.get("revision")
require(revision is None or REVISION.fullmatch(revision), "invalid fetch revision")
with selected_snapshot(root, fingerprint, revision,
- action == "fetch-pending") as (snapshot, _):
+ action == "fetch-pending", target) as (snapshot, _):
write_archive(snapshot, sys.stdout.buffer)
return None
def fetch_into(args, output, pending_only=False):
+ target = getattr(args, "target", DEFAULT_TARGET)
if args.ssh:
remote_call(args, {
"action": "fetch-pending" if pending_only else "fetch",
"root": args.root,
+ "target": target,
"fingerprint": args.fingerprint,
"revision": getattr(args, "revision", None),
}, output=output)
else:
with selected_snapshot(root_path(args.root), args.fingerprint,
getattr(args, "revision", None),
- pending_only) as (snapshot, _):
+ pending_only, target) as (snapshot, _):
shutil.copytree(snapshot, output, dirs_exist_ok=True)
historical = not pending_only or getattr(args, "allow_expired", False)
- return verify_signed(output, args.public_key, args.fingerprint, historical=historical)
+ return verify_signed(output, args.public_key, args.fingerprint,
+ historical=historical, target=target)
def parser():
@@ -653,6 +727,8 @@ def parser():
help="absolute origin root; HTTP serves ROOT/public")
action.add_argument("--public-key", required=True, type=Path)
action.add_argument("--fingerprint", required=True)
+ action.add_argument("--target", choices=tuple(TARGETS), default=DEFAULT_TARGET,
+ help="isolated repository target (defaults to Fedora 44 x86_64)")
action.add_argument("--ssh", metavar="USER@HOST",
help="omit for a local POSIX origin")
action.add_argument("--ssh-port", type=int)
@@ -680,6 +756,9 @@ def parser():
def run(args):
+ target, config, _package_path = target_config(
+ getattr(args, "target", DEFAULT_TARGET)
+ )
if args.ssh:
requested_root = Path(args.root)
require(requested_root.is_absolute() and args.root != "/"
@@ -704,23 +783,26 @@ def run(args):
if args.action == "publish":
require(args.expected_revision == "empty" or REVISION.fullmatch(args.expected_revision),
"invalid expected revision")
- manifest = verify_signed(args.repository, args.public_key, args.fingerprint)
+ manifest = verify_signed(args.repository, args.public_key, args.fingerprint,
+ target=target)
if args.dry_run:
return {"status": "validated", "revision": manifest["revision"],
"originChecked": False}
with tempfile.TemporaryDirectory(prefix="loopwire-rpm-candidate-") as directory:
candidate = Path(directory) / "repository"
shutil.copytree(args.repository, candidate, symlinks=True)
- require(verify_signed(candidate, args.public_key, args.fingerprint) == manifest,
+ require(verify_signed(candidate, args.public_key, args.fingerprint,
+ target=target) == manifest,
"candidate changed during verification")
if args.ssh:
return remote_call(args, {
"action": "publish", "root": args.root,
+ "target": target,
"fingerprint": args.fingerprint,
"expected": args.expected_revision,
}, repository=candidate)
return publish_at(root_path(args.root), candidate, args.fingerprint,
- args.expected_revision)
+ args.expected_revision, target)
if args.action == "fetch":
require(args.revision is None or REVISION.fullmatch(args.revision),
"invalid selected revision")
@@ -744,16 +826,18 @@ def run(args):
if args.ssh:
result = remote_call(args, {
"action": "recover", "root": args.root,
+ "target": target,
"fingerprint": args.fingerprint, "revision": manifest["revision"],
})
else:
result = recover_at(root_path(args.root), args.fingerprint,
- manifest["revision"])
+ manifest["revision"], target)
result["requiresRefresh"] = needs_refresh
if needs_refresh:
result["nextAction"] = (
"Immediately fetch, rebuild, sign, and publish fresh metadata; "
- "the project verifier rejects the expired snapshot. DNF signature checks do not enforce this project deadline."
+ f"the project verifier rejects the expired snapshot. {config['client']} "
+ "signature checks do not enforce this project deadline."
)
return result
diff --git a/scripts/rpm-repository.py b/scripts/rpm-repository.py
index 3ef1fa9..8b7de63 100644
--- a/scripts/rpm-repository.py
+++ b/scripts/rpm-repository.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""Build and verify immutable signed Loopwire Fedora repository candidates.
+"""Build and verify immutable signed Loopwire RPM-md repository candidates.
Requires Python's standard library, createrepo_c, rpm, rpmkeys, rpmsign,
gpg, gpgv, and openssl. The publisher retains immutable package and metadata
@@ -8,7 +8,7 @@
changes host repository configuration.
The explicit --date fixes repository timestamps and GnuPG signature creation
-times. Byte-for-byte repeatability still requires the pinned Fedora tool image,
+times. Byte-for-byte repeatability still requires the pinned target tool image,
the same key material, and a deterministic OpenPGP algorithm; a prior package
with identical source bytes is reused rather than signed again.
"""
@@ -32,7 +32,27 @@
SCHEMA = "loopwire.rpm-repository.v1"
MANIFEST = "repository-manifest.json"
-TARGET = {"distribution": "fedora", "release": "44", "architecture": "x86_64"}
+RELEASE_MANIFEST_SCHEMA = "loopwire.release-assets.v1"
+FEDORA = "fedora-44-x86_64"
+OPENSUSE = "opensuse-tumbleweed-x86_64"
+TARGETS = {
+ FEDORA: {
+ "manifest": {"distribution": "fedora", "release": "44", "architecture": "x86_64"},
+ "rpmRelease": "1.fc44",
+ "filename": "loopwire-{version}-1.fc44.x86_64.rpm",
+ "distroCpe": "cpe:/o:fedoraproject:fedora:44",
+ "distroName": "Fedora 44",
+ "releaseManifest": False,
+ },
+ OPENSUSE: {
+ "manifest": {"distribution": "opensuse", "release": "tumbleweed", "architecture": "x86_64"},
+ "rpmRelease": "1",
+ "filename": "loopwire-{version}-1.x86_64.rpm",
+ "distroCpe": "cpe:/o:opensuse:tumbleweed",
+ "distroName": "openSUSE Tumbleweed",
+ "releaseManifest": True,
+ },
+}
VERSION = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:\+[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?"
HASH = r"[0-9a-f]{64}"
FINGERPRINT = r"(?:[0-9A-F]{40}|[0-9A-F]{64})"
@@ -40,6 +60,7 @@
"name", "version", "release", "architecture", "path",
"sourceReleaseSha256", "distributedSha256", "size",
}
+OPENSUSE_PACKAGE_FIELDS = PACKAGE_FIELDS | {"sourceRevision"}
FILE_FIELDS = {"path", "sha256", "size", "kind"}
ROOT = Path(__file__).resolve().parent.parent
REPO_NS = "http://linux.duke.edu/metadata/repo"
@@ -92,6 +113,22 @@ def hash_value(value, label):
return value
+def verify_release_signature(public_key, signature, checksums):
+ description = run(
+ "openssl", "pkey", "-pubin", "-in", public_key, "-text_pub", "-noout",
+ ).decode("utf-8", errors="replace")
+ match = re.search(r"^Public-Key: \(([0-9]+) bit\)$", description, re.MULTILINE)
+ require(match is not None and "Modulus:" in description and "Exponent:" in description,
+ "release verification key must be RSA")
+ expected_size = (int(match.group(1)) + 7) // 8
+ require(signature.stat().st_size == expected_size,
+ "release checksum signature length does not match its RSA key")
+ run(
+ "openssl", "dgst", "-sha256", "-verify", public_key,
+ "-signature", signature, checksums,
+ )
+
+
def integer(value, label, minimum=0):
require(type(value) is int and value >= minimum, f"invalid {label}")
return value
@@ -127,17 +164,34 @@ def safe_path(value):
return value
-def classify_path(value):
+def target_config(slug):
+ require(slug in TARGETS, f"unsupported RPM repository target: {slug}")
+ return TARGETS[slug]
+
+
+def target_from_manifest(value):
+ for slug, config in TARGETS.items():
+ if value == config["manifest"]:
+ return slug, config
+ raise RepositoryError("repository target is unsupported")
+
+
+def package_fields(config):
+ return OPENSUSE_PACKAGE_FIELDS if config["releaseManifest"] else PACKAGE_FIELDS
+
+
+def classify_path(value, config):
safe_path(value)
if re.fullmatch(rf"keys/{FINGERPRINT}\.asc", value):
return "immutable"
- if re.fullmatch(rf"packages/loopwire-{VERSION}-1\.fc44\.x86_64\.rpm", value):
+ filename = re.escape(config["filename"]).replace(re.escape("{version}"), VERSION)
+ if re.fullmatch(rf"packages/{filename}", value):
return "immutable"
if re.fullmatch(rf"repodata/{HASH}-(?:primary|filelists|other)\.xml\.gz", value):
return "immutable"
if value in ("repodata/repomd.xml", "repodata/repomd.xml.asc"):
return "metadata"
- raise RepositoryError(f"path is outside the Fedora repository contract: {value}")
+ raise RepositoryError(f"path is outside the RPM repository target contract: {value}")
def regular_file(path):
@@ -193,7 +247,7 @@ def manifest_revision(manifest):
return hashlib.sha256(canonical(unsigned)).hexdigest()
-def rpm_identity(path):
+def rpm_identity(path, config):
regular_file(path)
fields = run(
"rpm", "-qp", "--queryformat",
@@ -203,17 +257,20 @@ def rpm_identity(path):
name, version, release, architecture, epoch = fields
require(name == "loopwire", "repository only accepts RPM Name: loopwire")
require(re.fullmatch(VERSION, version) is not None, f"unsupported RPM version: {version}")
- require(release == "1.fc44", f"repository only accepts RPM Release: 1.fc44, got {release}")
+ require(release == config["rpmRelease"],
+ f"repository only accepts RPM Release: {config['rpmRelease']}, got {release}")
require(architecture == "x86_64", f"repository only accepts RPM Architecture: x86_64, got {architecture}")
require(epoch in ("0", "(none)"), "repository RPM must not set a nonzero epoch")
return name, version, release, architecture
def rpm_has_signature(path):
+ available = set(run("rpm", "--querytags").decode("ascii").splitlines())
+ tags = [tag for tag in ("OPENPGP", "SIGPGP", "SIGGPG", "RSAHEADER", "DSAHEADER") if tag in available]
+ require(tags, "RPM tool exposes no supported OpenPGP signature tags")
output = run(
"rpm", "-qp", "--queryformat",
- "%{OPENPGP:pgpsig}\n%{SIGPGP:pgpsig}\n%{SIGGPG:pgpsig}\n"
- "%{RSAHEADER:pgpsig}\n%{DSAHEADER:pgpsig}\n", path,
+ "".join(f"%{{{tag}:pgpsig}}\n" for tag in tags), path,
).decode("utf-8", errors="replace")
return any(value.strip() not in ("", "(none)") for value in output.splitlines())
@@ -231,27 +288,30 @@ def verify_rpm_signature(path, public_key):
run("rpmkeys", "--dbpath", database, "--checksig", path)
-def package_info(root, path, source_hash, public_key):
- classify_path(path)
+def package_info(root, path, provenance, public_key, config):
+ classify_path(path, config)
require(path.startswith("packages/"), "RPM path is outside packages/")
package = root / path
verify_rpm_digest(package)
verify_rpm_signature(package, public_key)
- name, version, release, architecture = rpm_identity(package)
+ name, version, release, architecture = rpm_identity(package, config)
require(
Path(path).name == f"{name}-{version}-{release}.{architecture}.rpm",
"RPM filename does not match its NEVRA identity",
)
- return {
+ result = {
"name": name,
"version": version,
"release": release,
"architecture": architecture,
"path": path,
- "sourceReleaseSha256": hash_value(source_hash, "source release SHA256"),
+ "sourceReleaseSha256": hash_value(provenance["sha256"], "source release SHA256"),
"distributedSha256": sha256(package),
"size": package.stat().st_size,
}
+ if config["releaseManifest"]:
+ result["sourceRevision"] = provenance["revision"]
+ return result
def verify_detached_signature(data, signature, keyring, home, expected):
@@ -278,7 +338,7 @@ def verify_detached_signature(data, signature, keyring, home, expected):
)
-def load_inventory(root):
+def load_inventory(root, expected_target=None):
actual = tree_files(root)
require(MANIFEST in actual, "missing repository-manifest.json")
manifest = read_json(root / MANIFEST)
@@ -295,8 +355,11 @@ def load_inventory(root):
integer(manifest["createdAt"], "createdAt")
integer(manifest["validUntil"], "validUntil")
require(manifest["validUntil"] > manifest["createdAt"], "metadata expiry must follow creation")
- exact_keys(manifest["target"], TARGET, "repository target")
- require(manifest["target"] == TARGET, "repository target must be Fedora 44 x86_64")
+ require(isinstance(manifest["target"], dict), "repository target must be an object")
+ slug, config = target_from_manifest(manifest["target"])
+ exact_keys(manifest["target"], config["manifest"], "repository target")
+ if expected_target is not None:
+ require(slug == expected_target, "repository target differs from operator pin")
require(manifest["revision"] == manifest_revision(manifest), "manifest revision digest mismatch")
require(isinstance(manifest["packages"], list) and manifest["packages"], "packages must be a nonempty array")
require(isinstance(manifest["files"], list), "files must be an array")
@@ -305,7 +368,7 @@ def load_inventory(root):
exact_keys(entry, FILE_FIELDS, "inventory entry")
path = safe_path(entry["path"])
require(path not in inventory, f"duplicate inventory path: {path}")
- require(entry["kind"] == classify_path(path), f"incorrect file classification: {path}")
+ require(entry["kind"] == classify_path(path, config), f"incorrect file classification: {path}")
integer(entry["size"], "file size")
hash_value(entry["sha256"], f"SHA256 for {path}")
require(path in actual, f"missing inventory file: {path}")
@@ -319,7 +382,7 @@ def load_inventory(root):
f"repodata content filename/checksum mismatch: {path}")
inventory[path] = entry
require(actual == set(inventory) | {MANIFEST}, "repository has unlisted files")
- return manifest, inventory
+ return manifest, inventory, config
def xml(root, name):
@@ -328,7 +391,7 @@ def xml(root, name):
return values[0]
-def parse_repomd(root, manifest, inventory):
+def parse_repomd(root, manifest, inventory, config):
path = root / "repodata/repomd.xml"
raw = path.read_bytes()
require(b"= previous["createdAt"], "new metadata date must not precede the previous revision")
copy_immutable(previous_path, working, previous)
@@ -694,7 +848,7 @@ def write_candidate(args, rollback=False):
else:
packages = []
if not rollback:
- signed_release_package(args, working, packages, expected, key, staging, date)
+ signed_release_package(args, working, packages, expected, key, staging, date, config)
require(packages, "repository package set must not be empty")
exported = working / f"keys/{expected}.asc"
exported.parent.mkdir(parents=True, exist_ok=True)
@@ -703,11 +857,11 @@ def write_candidate(args, rollback=False):
"fingerprint-addressed key bytes changed; rotate trust before changing exported packets")
else:
shutil.copyfile(key, exported)
- generate_metadata(working, packages, date, valid_until, gpg, expected, staging)
+ generate_metadata(working, packages, date, valid_until, gpg, expected, staging, config)
files = [
{
"path": path, "sha256": sha256(working / path),
- "size": (working / path).stat().st_size, "kind": classify_path(path),
+ "size": (working / path).stat().st_size, "kind": classify_path(path, config),
}
for path in sorted(tree_files(working))
]
@@ -717,13 +871,13 @@ def write_candidate(args, rollback=False):
"signingFingerprint": expected,
"createdAt": date,
"validUntil": valid_until,
- "target": TARGET,
+ "target": config["manifest"],
"packages": packages,
"files": files,
}
manifest["revision"] = manifest_revision(manifest)
(working / MANIFEST).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
- verify_repository(working, key, expected, date)
+ verify_repository(working, key, expected, date, target)
working.rename(output)
return manifest
@@ -731,9 +885,11 @@ def write_candidate(args, rollback=False):
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
- build = commands.add_parser("build", help="generate a Fedora candidate from signed native release assets")
+ build = commands.add_parser("build", help="generate an RPM-md candidate from signed native release assets")
build.add_argument("--release-dir", type=Path, required=True)
build.add_argument("--version", required=True)
+ build.add_argument("--target", choices=tuple(TARGETS), default=FEDORA,
+ help=f"repository target (default: {FEDORA})")
build.add_argument(
"--release-public-key", type=Path, default=ROOT / "packaging/release-signing-public.pem",
help="trusted release checksum PEM (override for fixture keys)",
@@ -741,6 +897,8 @@ def main():
build.add_argument("--previous", type=Path, help="verified previous snapshot whose immutable history is retained")
rollback = commands.add_parser("rollback", help="freshly sign a retained snapshot's previous package set")
rollback.add_argument("--repository", type=Path, required=True)
+ rollback.add_argument("--target", choices=tuple(TARGETS),
+ help="optional target pin; otherwise derived from the retained snapshot")
for command in (build, rollback):
command.add_argument("--output", type=Path, required=True)
command.add_argument("--signing-key", required=True, help="uppercase primary OpenPGP fingerprint")
@@ -750,15 +908,17 @@ def main():
help="protected file containing the signing-key passphrase; never pass the secret directly")
command.add_argument("--date", type=int, help="metadata/signature creation time as Unix epoch seconds")
command.add_argument("--valid-for-days", type=int, default=30)
- verify = commands.add_parser("verify", help="verify the complete pinned Fedora repository trust chain")
+ verify = commands.add_parser("verify", help="verify the complete pinned RPM repository trust chain")
verify.add_argument("--repository", type=Path, required=True)
verify.add_argument("--public-key", type=Path, required=True,
help="independently trusted ASCII-armored repository key")
verify.add_argument("--fingerprint", required=True, help="expected uppercase primary fingerprint")
+ verify.add_argument("--target", choices=tuple(TARGETS),
+ help="optional target pin; otherwise derived from the manifest")
verify.add_argument("--now", type=int, help="explicit verification time for fixtures or historical snapshots")
args = parser.parse_args()
if args.command == "verify":
- manifest = verify_repository(args.repository, args.public_key, args.fingerprint, args.now)
+ manifest = verify_repository(args.repository, args.public_key, args.fingerprint, args.now, args.target)
else:
manifest = write_candidate(args, rollback=args.command == "rollback")
print(json.dumps({
diff --git a/scripts/setup-opensuse-repository.sh b/scripts/setup-opensuse-repository.sh
new file mode 100755
index 0000000..6a5c9b7
--- /dev/null
+++ b/scripts/setup-opensuse-repository.sh
@@ -0,0 +1,165 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+base_url=""
+fingerprint=""
+install_root="/"
+remove="false"
+dry_run="false"
+
+fail() { printf 'setup-opensuse-repository: %s\n' "$*" >&2; exit 1; }
+usage() {
+ cat <<'USAGE'
+Configure Loopwire's signed project repository on openSUSE Tumbleweed x86_64.
+
+Usage:
+ sudo bash setup-opensuse-repository.sh --base-url HTTPS_URL --fingerprint OPENPGP_FINGERPRINT
+ sudo bash setup-opensuse-repository.sh --remove
+ bash setup-opensuse-repository.sh --base-url HTTPS_URL --fingerprint FINGERPRINT --dry-run
+
+Options:
+ --root DIR Configure an offline filesystem tree instead of / (including its etc/os-release).
+
+Obtain the URL and fingerprint from the verified Loopwire channel documentation.
+Requires curl, GnuPG, Python 3, and RPM. Existing unrelated Zypper repositories are preserved.
+This writes only the repository and pinned key files. Refresh and install are separate reviewed steps.
+USAGE
+}
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --base-url) base_url="${2:?missing --base-url value}"; shift 2 ;;
+ --fingerprint) fingerprint="${2:?missing --fingerprint value}"; shift 2 ;;
+ --root) install_root="${2:?missing --root value}"; shift 2 ;;
+ --remove) remove="true"; shift ;;
+ --dry-run) dry_run="true"; shift ;;
+ -h|--help) usage; exit 0 ;;
+ *) fail "unknown option: $1" ;;
+ esac
+done
+
+install_root="$(realpath -e "$install_root")"
+[ -d "$install_root" ] || fail "root must be an existing directory"
+repo_file="${install_root%/}/etc/zypp/repos.d/loopwire.repo"
+key_directory="${install_root%/}/etc/zypp/keys"
+python3 - "$install_root" "$repo_file" "$key_directory" <<'PY'
+import sys
+from pathlib import Path
+root = Path(sys.argv[1])
+for name in sys.argv[2:]:
+ path = Path(name)
+ for part in (path, *path.parents):
+ if part == root:
+ break
+ if part.is_symlink():
+ sys.exit('setup-opensuse-repository: refusing symbolic links inside the target Zypper configuration tree')
+ if not part.is_relative_to(root):
+ sys.exit('setup-opensuse-repository: configuration path leaves the target root')
+PY
+owner_marker="# Managed by Loopwire openSUSE repository setup"
+[ ! -L "$repo_file" ] || fail "refusing a symbolic-link repository file"
+if [ -e "$repo_file" ] && ! head -n 1 "$repo_file" | grep -Fxq "$owner_marker"; then
+ fail "loopwire.repo already exists and is not managed by this helper"
+fi
+if [ "$install_root" = / ] && [ "$dry_run" != true ] && [ "$EUID" -ne 0 ]; then
+ fail "run with sudo to change system repository configuration, or use --dry-run"
+fi
+
+if [ "$remove" = true ]; then
+ if [ "$dry_run" = true ]; then
+ printf 'Would remove the managed Loopwire openSUSE repository and pinned key file.\n'
+ exit 0
+ fi
+ if [ -f "$repo_file" ]; then
+ key_name="$(sed -n 's|^gpgkey=file:///etc/zypp/keys/\(loopwire-repository-[A-F0-9]*\.asc\)$|\1|p' "$repo_file")"
+ [[ "$key_name" =~ ^loopwire-repository-[A-F0-9]{40}\.asc$ ]] || fail "managed repository has an unexpected key path"
+ rm -- "$repo_file"
+ rm -f -- "$key_directory/$key_name"
+ fi
+ printf 'Loopwire openSUSE repository removed. Installed packages, RPM database keys, and other repositories are unchanged.\n'
+ exit 0
+fi
+
+for command in curl gpg python3 rpm; do
+ command -v "$command" >/dev/null 2>&1 || fail "$command is required"
+done
+fingerprint="${fingerprint^^}"
+[[ "$fingerprint" =~ ^[A-F0-9]{40}$ ]] || fail "provide a complete 40-character OpenPGP fingerprint"
+base_url="$(python3 - "$base_url" <<'PY'
+import sys
+from urllib.parse import urlsplit
+value = sys.argv[1]
+try:
+ url = urlsplit(value)
+ valid = (url.scheme == 'https' and url.hostname and not url.username and not url.password
+ and not any(char in value for char in "\\'\"`$<>?#")
+ and all(32 < ord(char) < 127 for char in value))
+ if not valid:
+ raise ValueError('invalid URL')
+ if url.port is not None and not 1 <= url.port <= 65535:
+ raise ValueError('invalid port')
+except ValueError:
+ sys.exit('setup-opensuse-repository: base URL must be HTTPS without credentials, whitespace, query, or fragment')
+print(value.rstrip('/'))
+PY
+)"
+python3 - "${install_root%/}/etc/os-release" <<'PY'
+import shlex
+import sys
+from pathlib import Path
+values = {}
+for line in Path(sys.argv[1]).read_text().splitlines():
+ if '=' in line and not line.lstrip().startswith('#'):
+ key, value = line.split('=', 1)
+ fields = shlex.split(value)
+ if len(fields) == 1:
+ values[key] = fields[0]
+if values.get('ID') != 'opensuse-tumbleweed':
+ sys.exit('setup-opensuse-repository: supported system is openSUSE Tumbleweed')
+PY
+[ "$(rpm --eval '%{_arch}')" = x86_64 ] || fail "this channel currently supports x86_64 only"
+key_name="loopwire-repository-${fingerprint}.asc"
+[ ! -L "$key_directory/$key_name" ] || fail "refusing a symbolic-link key file"
+if [ "$dry_run" = true ]; then
+ printf 'Would verify %s/keys/%s.asc and configure Tumbleweed x86_64 with strict RPM and metadata signature checks.\n' \
+ "$base_url" "$fingerprint"
+ exit 0
+fi
+
+temporary="$(mktemp -d)"
+key_temporary=""
+repo_temporary=""
+cleanup() {
+ rm -rf -- "$temporary"
+ [ -z "$key_temporary" ] || rm -f -- "$key_temporary"
+ [ -z "$repo_temporary" ] || rm -f -- "$repo_temporary"
+}
+trap cleanup EXIT
+mkdir -m 0700 "$temporary/gnupg"
+curl --disable --fail --silent --show-error --proto '=https' --tlsv1.2 \
+ --connect-timeout 10 --max-time 60 --output "$temporary/key.asc" "$base_url/keys/$fingerprint.asc"
+actual="$(gpg --no-options --batch --homedir "$temporary/gnupg" --with-colons --show-keys "$temporary/key.asc" |
+ awk -F: '$1 == "pub" { count++ } $1 == "fpr" && !seen { print $10; seen=1 } END { if (count != 1) exit 1 }')"
+[ "$actual" = "$fingerprint" ] || fail "downloaded key does not match the expected fingerprint"
+cat >"$temporary/loopwire.repo" <", "rsa2048", "sign", "1d")
+ listing = run("gpg", "--homedir", str(cls.home), "--with-colons", "--list-keys").stdout
+ cls.fingerprint = next(line.split(":")[9] for line in listing.splitlines() if line.startswith("fpr:"))
+ cls.public = run("gpg", "--homedir", str(cls.home), "--armor", "--export", cls.fingerprint).stdout
+ cls.web = cls.work / "web"
+ (cls.web / "keys").mkdir(parents=True)
+ (cls.web / "keys" / f"{cls.fingerprint}.asc").write_text(cls.public)
+ (cls.web / "keys" / f"{'A' * 40}.asc").write_text(cls.public)
+ cert, key = cls.work / "cert.pem", cls.work / "key.pem"
+ run("openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-subj", "/CN=127.0.0.1",
+ "-addext", "subjectAltName=IP:127.0.0.1", "-keyout", str(key), "-out", str(cert))
+ cls.requests = []
+
+ class Handler(http.server.SimpleHTTPRequestHandler):
+ def log_message(self, *_args):
+ pass
+
+ def do_GET(self):
+ cls.requests.append(self.path)
+ super().do_GET()
+
+ cls.server = http.server.ThreadingHTTPServer(
+ ("127.0.0.1", 0), functools.partial(Handler, directory=str(cls.web)))
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ context.load_cert_chain(cert, key)
+ cls.server.socket = context.wrap_socket(cls.server.socket, server_side=True)
+ threading.Thread(target=cls.server.serve_forever, daemon=True).start()
+ cls.url = f"https://127.0.0.1:{cls.server.server_port}"
+ cls.binary = cls.work / "bin"
+ cls.binary.mkdir()
+ rpm = cls.binary / "rpm"
+ rpm.write_text('#!/bin/sh\nprintf "%s\\n" "${TEST_ARCH:-x86_64}"\n')
+ rpm.chmod(0o755)
+ cls.environment = {**os.environ, "PATH": f"{cls.binary}:{os.environ['PATH']}", "CURL_CA_BUNDLE": str(cert)}
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.server.shutdown()
+ cls.server.server_close()
+ subprocess.run(["gpgconf", "--homedir", str(cls.home), "--kill", "all"], check=False,
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ cls.temporary.cleanup()
+
+ def setUp(self):
+ self.root = self.work / "root"
+ shutil.rmtree(self.root, ignore_errors=True)
+ (self.root / "etc").mkdir(parents=True)
+ (self.root / "etc/os-release").write_text('ID="opensuse-tumbleweed"\nVERSION_ID="20260829"\n')
+ self.repo = self.root / "etc/zypp/repos.d/loopwire.repo"
+ self.key = self.root / f"etc/zypp/keys/loopwire-repository-{self.fingerprint}.asc"
+ self.requests.clear()
+
+ def invoke(self, *args, fingerprint=None, url=None, env=None):
+ return subprocess.run([
+ "bash", str(SCRIPT), "--root", str(self.root), "--base-url", url or self.url,
+ "--fingerprint", fingerprint or self.fingerprint, *args,
+ ], env={**self.environment, **(env or {})}, capture_output=True, text=True)
+
+ def test_configuration_is_strict_and_idempotent(self):
+ for _ in range(2):
+ result = self.invoke()
+ self.assertEqual(result.returncode, 0, result.stderr)
+ text = self.repo.read_text()
+ for line in [f"baseurl={self.url}", "autorefresh=1", "type=rpm-md", "priority=99", "gpgcheck=1",
+ "repo_gpgcheck=1", "pkg_gpgcheck=1",
+ f"gpgkey=file:///etc/zypp/keys/loopwire-repository-{self.fingerprint}.asc"]:
+ self.assertIn(line, text)
+ self.assertEqual(self.key.read_text(), self.public)
+ self.assertEqual(self.key.stat().st_mode & 0o777, 0o644)
+
+ def test_dry_run_has_no_network_or_writes(self):
+ result = self.invoke("--dry-run")
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(self.requests, [])
+ self.assertFalse(self.repo.exists())
+
+ def test_wrong_fingerprint_preserves_existing_configuration(self):
+ self.assertEqual(self.invoke().returncode, 0)
+ before = self.repo.read_bytes()
+ result = self.invoke(fingerprint="A" * 40)
+ self.assertNotEqual(result.returncode, 0)
+ self.assertEqual(self.repo.read_bytes(), before)
+
+ def test_bad_urls_platform_and_arch_fail_before_network(self):
+ for url in ["http://example.invalid", "https://user:pass@example.invalid", "https://example.invalid/?",
+ "https://example.invalid/#", "https://example.invalid/\ninjected"]:
+ with self.subTest(url=url):
+ self.assertNotEqual(self.invoke(url=url).returncode, 0)
+ (self.root / "etc/os-release").write_text('ID="opensuse-leap"\nVERSION_ID="16.0"\n')
+ self.assertNotEqual(self.invoke().returncode, 0)
+ (self.root / "etc/os-release").write_text('ID="opensuse-tumbleweed"\nVERSION_ID="20260829"\n')
+ self.assertNotEqual(self.invoke(env={"TEST_ARCH": "aarch64"}).returncode, 0)
+ self.assertEqual(self.requests, [])
+
+ def test_os_release_is_data(self):
+ sentinel = self.work / "must-not-exist"
+ (self.root / "etc/os-release").write_text(f'ID="$(touch {sentinel})"\nVERSION_ID=20260829\n')
+ self.assertNotEqual(self.invoke().returncode, 0)
+ self.assertFalse(sentinel.exists())
+
+ def test_unmanaged_and_symlinked_paths_are_preserved(self):
+ self.repo.parent.mkdir(parents=True)
+ self.repo.write_text("# other owner\n")
+ self.assertNotEqual(self.invoke().returncode, 0)
+ self.assertNotEqual(self.invoke("--remove").returncode, 0)
+ self.repo.unlink()
+ outside = self.work / "outside"
+ outside.mkdir(exist_ok=True)
+ (self.root / "etc/zypp/repos.d").rmdir()
+ (self.root / "etc/zypp/repos.d").symlink_to(outside, target_is_directory=True)
+ self.assertNotEqual(self.invoke().returncode, 0)
+ self.assertEqual(list(outside.iterdir()), [])
+
+ def test_remove_is_idempotent_and_preserves_other_repositories(self):
+ self.assertEqual(self.invoke().returncode, 0)
+ other = self.repo.with_name("unrelated.repo")
+ other.write_text("keep")
+ for _ in range(2):
+ result = self.invoke("--remove")
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertFalse(self.repo.exists())
+ self.assertFalse(self.key.exists())
+ self.assertEqual(other.read_text(), "keep")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/test-opensuse-public.py b/scripts/test-opensuse-public.py
new file mode 100755
index 0000000..4f4a862
--- /dev/null
+++ b/scripts/test-opensuse-public.py
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+import contextlib
+import functools
+import hashlib
+import http.server
+import importlib.util
+import io
+import json
+from pathlib import Path
+import ssl
+import subprocess
+import sys
+import tempfile
+import threading
+import unittest
+from unittest.mock import patch
+
+
+SCRIPT = Path(__file__).with_name("verify-opensuse-public.py")
+spec = importlib.util.spec_from_file_location("opensuse_public", SCRIPT)
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+
+
+class PublicTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.temporary = tempfile.TemporaryDirectory(prefix="loopwire-opensuse-public-")
+ cls.root = Path(cls.temporary.name)
+ cls.web = cls.root / "web"
+ cls.web.mkdir()
+ cls.cert, key = cls.root / "cert.pem", cls.root / "key.pem"
+ subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
+ "-subj", "/CN=127.0.0.1", "-addext", "subjectAltName=IP:127.0.0.1",
+ "-keyout", str(key), "-out", str(cls.cert)], check=True, capture_output=True)
+
+ class Handler(http.server.SimpleHTTPRequestHandler):
+ def log_message(self, *_args):
+ pass
+
+ cls.server = http.server.ThreadingHTTPServer(
+ ("127.0.0.1", 0), functools.partial(Handler, directory=str(cls.web)))
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ context.load_cert_chain(cls.cert, key)
+ cls.server.socket = context.wrap_socket(cls.server.socket, server_side=True)
+ threading.Thread(target=cls.server.serve_forever, daemon=True).start()
+ cls.url = f"https://127.0.0.1:{cls.server.server_port}"
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.server.shutdown()
+ cls.server.server_close()
+ cls.temporary.cleanup()
+
+ def setUp(self):
+ payload = b"signed rpm bytes"
+ (self.web / "packages").mkdir(exist_ok=True)
+ (self.web / "packages/loopwire.rpm").write_bytes(payload)
+ manifest = json.dumps({
+ "target": {"distribution": "opensuse", "release": "tumbleweed", "architecture": "x86_64"},
+ "revision": "a" * 64,
+ "files": [{"path": "packages/loopwire.rpm", "size": len(payload),
+ "sha256": hashlib.sha256(payload).hexdigest()}],
+ })
+ (self.root / "repository-manifest.json").write_text(manifest)
+ (self.web / "repository-manifest.json").write_text(manifest)
+ self.output = self.root / "channel.json"
+ self.output.unlink(missing_ok=True)
+
+ def invoke(self, *extra, verifier_error=None):
+ args = [str(SCRIPT), "--repository", str(self.root), "--public-key", str(self.root / "key.asc"),
+ "--fingerprint", "A" * 40, "--base-url", self.url]
+ if "--output" not in extra:
+ args += ["--ca-file", str(self.cert)]
+ args += extra
+ trust = ssl.create_default_context(cafile=str(self.cert))
+ with patch.object(sys, "argv", args), patch.object(module.subprocess, "run") as verifier, \
+ patch.object(module.ssl, "create_default_context", return_value=trust), \
+ contextlib.redirect_stdout(io.StringIO()) as output:
+ if verifier_error:
+ verifier.side_effect = verifier_error
+ module.main()
+ verifier.assert_called_once()
+ self.assertTrue(verifier.call_args.kwargs["check"])
+ self.assertIn("--target", verifier.call_args.args[0])
+ self.assertIn("opensuse-tumbleweed-x86_64", verifier.call_args.args[0])
+ return json.loads(output.getvalue())
+
+ def test_exact_public_bytes_produce_opensuse_record(self):
+ result = self.invoke("--output", str(self.output), "--proof-url",
+ "https://github.com/sandwichfarm/loopwire/actions/runs/123")
+ self.assertEqual(result["files"], 2)
+ record = json.loads(self.output.read_text())
+ self.assertEqual(record["target"], "opensuse-tumbleweed")
+ self.assertEqual(record["baseUrl"], self.url)
+
+ def test_package_or_manifest_tamper_fails(self):
+ for path in [self.web / "packages/loopwire.rpm", self.web / "repository-manifest.json"]:
+ with self.subTest(path=path.name):
+ before = path.read_bytes()
+ path.write_bytes(b"tampered")
+ with self.assertRaises(ValueError):
+ self.invoke()
+ path.write_bytes(before)
+
+ def test_local_signature_failure_prevents_network(self):
+ with self.assertRaises(subprocess.CalledProcessError):
+ self.invoke(verifier_error=subprocess.CalledProcessError(1, "verify"))
+
+ def test_custom_ca_cannot_activate(self):
+ with self.assertRaisesRegex(ValueError, "custom-CA fixture"):
+ self.invoke("--output", str(self.output), "--ca-file", str(self.cert), "--proof-url",
+ "https://github.com/sandwichfarm/loopwire/actions/runs/123")
+
+ def test_invalid_url_fingerprint_and_proof_fail(self):
+ for args in [("--base-url", "http://example.invalid"), ("--fingerprint", "short"),
+ ("--output", str(self.output), "--proof-url", "https://example.invalid/run/1")]:
+ with self.subTest(args=args), self.assertRaises(ValueError):
+ self.invoke(*args)
+
+ def test_wrong_target_fails(self):
+ manifest = json.loads((self.root / "repository-manifest.json").read_text())
+ manifest["target"] = {"distribution": "fedora", "release": "44", "architecture": "x86_64"}
+ (self.root / "repository-manifest.json").write_text(json.dumps(manifest))
+ with self.assertRaisesRegex(ValueError, "openSUSE"):
+ self.invoke()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/test-opensuse-repository-vm-proof.mjs b/scripts/test-opensuse-repository-vm-proof.mjs
new file mode 100755
index 0000000..71ebbc6
--- /dev/null
+++ b/scripts/test-opensuse-repository-vm-proof.mjs
@@ -0,0 +1,182 @@
+#!/usr/bin/env node
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { parseInstalledHashes, parsePayloadRelease, parseReleaseChecksums, verifyReleaseSignature,
+ verifyRpmSignature } from "./verify-fedora-repository-vm-proof.mjs";
+import { verifyInstalledStage, verifyLifecycle, verifyPackageEntry, verifyReleaseAssetManifest,
+ verifyZypperInstalledSearch, verifyZypperSearch, targetManifestRow } from "./verify-opensuse-repository-vm-proof.mjs";
+
+const directory = await mkdtemp(path.join(tmpdir(), "loopwire-opensuse-proof-test-"));
+const baseline = "0.1.0-1";
+const upgrade = "0.1.0+zypperfixture1-1";
+const packageName = `loopwire-${baseline}.x86_64.rpm`;
+const packageSha256 = "b".repeat(64);
+const sourceSha256 = "c".repeat(64);
+const fingerprint = "1234567890ABCDEF1234567890ABCDEF12345678";
+const expectedHashes = Object.fromEntries([
+ "/usr/bin/loopwire", "/usr/bin/loopwire-dsp-provider", "/usr/bin/loopwire-jack-ports",
+ "/usr/bin/loopwire-detect-audio", "/usr/lib/loopwire/loopwire-gui",
+ "/usr/share/applications/loopwire.desktop", "/usr/share/icons/hicolor/scalable/apps/loopwire.svg",
+].map((name) => [name, "a".repeat(64)]));
+const signature = `${packageName}:\n Header V4 RSA/SHA256 Signature, key ID ${fingerprint.slice(-16).toLowerCase()}: OK\n Header SHA256 digest: OK\n Payload SHA256 digest: OK\n`;
+const transitions = [
+ `install\t${baseline}\tinstalled`, `reinstall\t${baseline}\tinstalled`, `upgrade\t${upgrade}\tinstalled`,
+ `rollback\t${baseline}\tinstalled`, `remove\t${baseline}\tabsent`,
+].join("\n");
+const releaseManifest = {
+ schema: "loopwire.release-assets.v1",
+ release: { tag: "v0.1.0", version: "0.1.0", gitHead: "e".repeat(40) },
+ artifacts: [
+ { name: packageName, kind: "native-rpm", target: "opensuse-tumbleweed", architecture: "x86_64",
+ bytes: 123, sha256: sourceSha256 },
+ { name: "loopwire-linux-x86_64.tar.gz", kind: "portable-archive", target: "linux-generic", architecture: "x86_64",
+ bytes: 456, sha256: "d".repeat(64) },
+ ],
+};
+const releaseExpected = { version: "0.1.0", rpmName: packageName, rpmBytes: 123, rpmSha256: sourceSha256,
+ tarBytes: 456, tarSha256: "d".repeat(64) };
+const zypperXml = `\n`;
+let passed = 0;
+
+async function test(name, action) {
+ await action();
+ passed += 1;
+ console.log(`PASS ${name}`);
+}
+async function stageFixture() {
+ await rm(path.join(directory, "install"), { recursive: true, force: true });
+ await mkdir(path.join(directory, "install"), { recursive: true });
+ const files = {
+ "package-metadata.tsv": `loopwire\t${baseline}\tx86_64\t(none)\n`,
+ "zypper-origin.tsv": `loopwire\t${baseline}\tx86_64\tLoopwire for openSUSE Tumbleweed - x86_64\t(none)\n`,
+ "zypper-search.xml": zypperXml,
+ "zypper-repository-search.xml": zypperXml.replace('status="installed" ', ''),
+ "zypper-info.txt": `Information for package loopwire:\nRepository : loopwire\nName : loopwire\nVersion : ${baseline}\nArch : x86_64\nVendor : (none)\nInstalled : Yes\n`,
+ "package-files.txt": `${Object.keys(expectedHashes).join("\n")}\n`,
+ "installed-files.sha256": `${Object.entries(expectedHashes).map(([name, hash]) => `${hash} ${name}`).join("\n")}\n`,
+ "signed-package.sha256": `${packageSha256} ${packageName}\n`, "rpm-signature.txt": signature,
+ "background-help.txt": "Usage: Loopwire background restore\n", "dsp-provider-help.txt": "Usage: Loopwire DSP provider\n",
+ "jack-provider-help.txt": "Usage: Loopwire JACK provider\n", "detect-audio.json": "{\"backends\":[]}\n",
+ "gui-ldd.txt": "libgtk-3.so.0 => /lib64/libgtk-3.so.0\nlibwebkit2gtk-4.1.so.0 => /lib64/libwebkit2gtk-4.1.so.0\n",
+ "gui-launch-status.txt": "0\n", "gui-window-ids.txt": "1234\n", "gui-window-names.txt": "Loopwire\n",
+ "gui-launch.log": "", "xvfb.log": "",
+ };
+ for (const [name, content] of Object.entries(files)) await writeFile(path.join(directory, "install", name), content);
+}
+async function verifyStage() {
+ await verifyInstalledStage(directory, "install", baseline, fingerprint, packageName, packageSha256, expectedHashes);
+}
+async function change(name, transform) {
+ const file = path.join(directory, "install", name);
+ await writeFile(file, transform(await readFile(file, "utf8")));
+}
+
+try {
+ await test("complete ordered lifecycle accepted", () => verifyLifecycle(transitions, baseline, upgrade));
+ await test("missing reinstall rejected", () => assert.throws(() => verifyLifecycle(
+ transitions.split("\n").filter((line) => !line.startsWith("reinstall")).join("\n"), baseline, upgrade), /lifecycle transitions/));
+ await test("rollback remaining upgraded rejected", () => assert.throws(() => verifyLifecycle(
+ transitions.replace(`rollback\t${baseline}`, `rollback\t${upgrade}`), baseline, upgrade), /lifecycle transitions/));
+ await test("fabricated lifecycle pass rejected", () => assert.throws(() => verifyLifecycle("pass", baseline, upgrade), /lifecycle transitions/));
+ await test("selected target manifest row accepted", () => assert.deepEqual(targetManifestRow(
+ `# header\nopensuse-tumbleweed\topenSUSE Tumbleweed\topensuse-tumbleweed\trpm\thttps://example.invalid/image\tsha256\t${"a".repeat(64)}\t2264\tbios\n`,
+ "opensuse-tumbleweed"), ["opensuse-tumbleweed", "openSUSE Tumbleweed", "opensuse-tumbleweed", "rpm",
+ "https://example.invalid/image", "sha256", "a".repeat(64), "2264", "bios"]));
+ await test("duplicate or malformed target manifest rows rejected", () => {
+ const row = `opensuse-tumbleweed\topenSUSE Tumbleweed\topensuse-tumbleweed\trpm\thttps://example.invalid/image\tsha256\t${"a".repeat(64)}\t2264\tbios`;
+ assert.throws(() => targetManifestRow(`${row}\n${row}\n`, "opensuse-tumbleweed"), /missing or duplicate/);
+ assert.throws(() => targetManifestRow(`${row}\textra\n`, "opensuse-tumbleweed"), /nine nonempty fields/);
+ });
+ await test("duplicate installed hash rejected", () => assert.throws(() => parseInstalledHashes(
+ `${"a".repeat(64)} /usr/bin/loopwire\n${"b".repeat(64)} /usr/bin/loopwire\n`), /duplicate/));
+ await test("selective public release checksums accepted", () => assert.equal(parseReleaseChecksums(
+ `${sourceSha256} ${packageName}\n${"d".repeat(64)} loopwire-linux-x86_64.tar.gz\n`).get(packageName), sourceSha256));
+ await test("duplicate public release checksum rejected", () => assert.throws(() => parseReleaseChecksums(
+ `${sourceSha256} ${packageName}\n${sourceSha256} ${packageName}\n`), /duplicate/));
+ await test("valid release signature accepted and changed manifest rejected", async () => {
+ const signing = path.join(directory, "release-signing");
+ await mkdir(signing);
+ const privateKey = path.join(signing, "private.pem");
+ const publicKey = path.join(signing, "public.pem");
+ const checksums = path.join(signing, "SHA256SUMS");
+ const signatureFile = path.join(signing, "SHA256SUMS.sig");
+ await writeFile(checksums, `${sourceSha256} ${packageName}\n`);
+ for (const args of [["genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", privateKey],
+ ["pkey", "-in", privateKey, "-pubout", "-out", publicKey],
+ ["dgst", "-sha256", "-sign", privateKey, "-out", signatureFile, checksums]]) {
+ const result = spawnSync("openssl", args, { encoding: "utf8" });
+ assert.equal(result.status, 0, result.stderr);
+ }
+ verifyReleaseSignature(checksums, signatureFile, publicKey);
+ await writeFile(checksums, `${"0".repeat(64)} ${packageName}\n`);
+ assert.throws(() => verifyReleaseSignature(checksums, signatureFile, publicKey), /openssl verification failed/);
+ });
+ await test("exact openSUSE release manifest accepted", () => verifyReleaseAssetManifest(
+ JSON.stringify(releaseManifest), releaseExpected));
+ await test("wrong release manifest target rejected", () => assert.throws(() => verifyReleaseAssetManifest(
+ JSON.stringify({ ...releaseManifest, artifacts: releaseManifest.artifacts.map((entry, index) => index ? entry : { ...entry, target: "fedora-44" }) }),
+ releaseExpected), /artifact count/));
+ await test("wrong release manifest hash rejected", () => assert.throws(() => verifyReleaseAssetManifest(
+ JSON.stringify({ ...releaseManifest, artifacts: releaseManifest.artifacts.map((entry, index) => index ? entry : { ...entry, sha256: "0".repeat(64) }) }),
+ releaseExpected), /openSUSE release artifact/));
+ await test("wrong release manifest version rejected", () => assert.throws(() => verifyReleaseAssetManifest(
+ JSON.stringify({ ...releaseManifest, release: { ...releaseManifest.release, version: "0.2.0" } }), releaseExpected), /public release version/));
+ const releaseText = "name=loopwire\nversion=0.1.0\narch=x86_64\nsource_date_epoch=1788115521\n";
+ await test("portable RELEASE accepted", () => parsePayloadRelease(releaseText, "0.1.0"));
+ await test("wrong portable RELEASE rejected", () => assert.throws(() => parsePayloadRelease(
+ releaseText.replace("version=0.1.0", "version=0.2.0"), "0.1.0"), /RELEASE version/));
+ await test("exact Zypper origin accepted", () => verifyZypperSearch(zypperXml, baseline));
+ await test("native installed-system Zypper view accepted", () => verifyZypperInstalledSearch(
+ zypperXml.replace('repository="Loopwire for openSUSE Tumbleweed - x86_64"', 'repository="(System Packages)"'),
+ baseline));
+ await test("wrong Zypper repository rejected", () => assert.throws(() => verifyZypperSearch(
+ zypperXml.replace('repository="Loopwire for openSUSE Tumbleweed - x86_64"', 'repository="@System"'), baseline),
+ /Zypper repository/));
+ await test("valid RPM signature accepted", () => verifyRpmSignature(signature, fingerprint, packageName));
+ await test("unsigned RPM rejected", () => assert.throws(() => verifyRpmSignature(
+ `${packageName}: digests OK\n`, fingerprint, packageName), /lacks/));
+ const packageEntry = { name: "loopwire", version: "0.1.0", release: "1", architecture: "x86_64",
+ path: `packages/${packageName}`, sourceReleaseSha256: sourceSha256, sourceRevision: "e".repeat(40),
+ distributedSha256: packageSha256, size: 123 };
+ await test("exact repository package accepted", () => verifyPackageEntry(packageEntry,
+ { version: "0.1.0", name: packageName, sourceRevision: "e".repeat(40) }, packageSha256, sourceSha256, "fixture"));
+ await test("public baseline source substitution rejected", () => assert.throws(() => verifyPackageEntry(
+ { ...packageEntry, sourceReleaseSha256: "d".repeat(64) }, { version: "0.1.0", name: packageName, sourceRevision: "e".repeat(40) },
+ packageSha256, sourceSha256, "fixture"), /source release hash/));
+ await test("public source revision substitution rejected", () => assert.throws(() => verifyPackageEntry(
+ { ...packageEntry, sourceRevision: "f".repeat(40) },
+ { version: "0.1.0", name: packageName, sourceRevision: "e".repeat(40) },
+ packageSha256, sourceSha256, "fixture"), /public source revision/));
+ await test("distributed package substitution rejected", () => assert.throws(() => verifyPackageEntry(
+ { ...packageEntry, distributedSha256: "d".repeat(64) },
+ { version: "0.1.0", name: packageName, sourceRevision: "e".repeat(40) },
+ packageSha256, sourceSha256, "fixture"), /distributed RPM hash/));
+
+ await stageFixture();
+ await test("complete installed stage accepted", verifyStage);
+ for (const [name, file, mutation, pattern] of [
+ ["changed installed bytes rejected", "installed-files.sha256", (value) => value.replace("a".repeat(64), "d".repeat(64)), /signed repository RPM payload/],
+ ["wrong installed version rejected", "package-metadata.tsv", (value) => value.replace(baseline, upgrade), /metadata/],
+ ["wrong vendor rejected", "package-metadata.tsv", (value) => value.replace("(none)", "Example Vendor"), /vendor/],
+ ["local package origin rejected", "zypper-origin.tsv", (value) => value.replace("\tLoopwire for openSUSE Tumbleweed - x86_64\t(none)", "\t@System\t(none)"), /origin/],
+ ["wrong Zypper XML origin rejected", "zypper-repository-search.xml", (value) => value.replace('repository="Loopwire for openSUSE Tumbleweed - x86_64"', 'repository="other"'), /Zypper repository/],
+ ["wrong signed RPM digest rejected", "signed-package.sha256", (value) => value.replace(packageSha256, "d".repeat(64)), /signed RPM digest/],
+ ["failed RPM signature rejected", "rpm-signature.txt", (value) => value.replace(": OK", ": NOKEY"), /signature verification failed/],
+ ["unresolved GUI dependency rejected", "gui-ldd.txt", (value) => `${value}libmissing.so => not found\n`, /linkage/],
+ ["unrelated X11 window rejected", "gui-window-names.txt", () => "xterm\n", /application window/],
+ ["failed GUI launch rejected", "gui-launch-status.txt", () => "124\n", /GUI launch/],
+ ["GUI panic rejected", "gui-launch.log", () => "thread main panicked\n", /fatal GUI log/],
+ ["empty provider output rejected", "dsp-provider-help.txt", () => "", /empty evidence/],
+ ["missing helper rejected", "package-files.txt", (value) => value.replace("/usr/bin/loopwire-dsp-provider\n", ""), /missing/],
+ ]) {
+ await stageFixture();
+ await change(file, mutation);
+ await test(name, () => assert.rejects(verifyStage, pattern));
+ }
+ console.log(`openSUSE VM proof verifier tests passed: ${passed}`);
+} finally {
+ await rm(directory, { recursive: true, force: true });
+}
diff --git a/scripts/test-opensuse-workflow-preflight.py b/scripts/test-opensuse-workflow-preflight.py
new file mode 100755
index 0000000..5144fa6
--- /dev/null
+++ b/scripts/test-opensuse-workflow-preflight.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+import os
+from pathlib import Path
+import subprocess
+import tempfile
+import unittest
+
+
+SCRIPT = Path(__file__).with_name("publish-opensuse-workflow.sh").resolve()
+
+
+class PreflightTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory(prefix="loopwire-opensuse-preflight-")
+ self.root = Path(self.temp.name)
+ self.addCleanup(self.temp.cleanup)
+ binary = self.root / "bin"
+ binary.mkdir()
+ gpg = binary / "gpg"
+ gpg.write_text('#!/bin/sh\nprintf called > "$OPENSUSE_TEST_MARKER"\nexit 1\n')
+ gpg.chmod(0o755)
+ self.marker = self.root / "used-key"
+ self.env = {
+ **os.environ, "PATH": f"{binary}:{os.environ['PATH']}", "OPENSUSE_TEST_MARKER": str(self.marker),
+ "OPENSUSE_REPOSITORY_URL": "https://packages.example.invalid/opensuse/tumbleweed/x86_64",
+ "OPENSUSE_REPOSITORY_HOST": "publisher@example.invalid",
+ "OPENSUSE_REPOSITORY_ROOT": "/srv/loopwire-rpm",
+ "OPENSUSE_SIGNING_FINGERPRINT": "A" * 40,
+ "OPENSUSE_SSH_PRIVATE_KEY": "private-ssh-fixture",
+ "OPENSUSE_SSH_KNOWN_HOSTS": "known-hosts-fixture",
+ "OPENSUSE_SIGNING_KEY": "private-gpg-fixture",
+ "RUNNER_TEMP": str(self.root), "GITHUB_REPOSITORY": "sandwichfarm/loopwire",
+ "GITHUB_SERVER_URL": "https://github.com", "GITHUB_RUN_ID": "123",
+ "OPERATION": "publish", "RELEASE_TAG": "v1.2.3",
+ }
+
+ def rejected(self, values, message):
+ result = subprocess.run(["bash", str(SCRIPT)], env={**self.env, **values}, capture_output=True, text=True)
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn(message, result.stderr)
+ self.assertFalse(self.marker.exists(), "invalid input reached signing-key operations")
+ self.assertNotIn("private-ssh-fixture", result.stdout + result.stderr)
+ self.assertNotIn("private-gpg-fixture", result.stdout + result.stderr)
+
+ def test_https_url_rejected_before_keys_or_origin_access(self):
+ for url in ["http://example.invalid", "https://user:pass@example.invalid", "https://example.invalid/?",
+ "https://example.invalid/#", "https://example.invalid/\ninjected"]:
+ with self.subTest(url=url):
+ self.rejected({"OPENSUSE_REPOSITORY_URL": url}, "base URL")
+
+ def test_missing_configuration(self):
+ self.rejected({"OPENSUSE_SIGNING_KEY": ""}, "missing configuration: OPENSUSE_SIGNING_KEY")
+
+ def test_tag_rollback_and_fingerprint_validation(self):
+ self.rejected({"RELEASE_TAG": "v1.2.3; unexpected"}, "stable vX.Y.Z")
+ self.rejected({"OPERATION": "rollback", "ROLLBACK_REVISION": "HEAD"}, "revision SHA-256")
+ self.rejected({"OPENSUSE_SIGNING_FINGERPRINT": "short"}, "fingerprint")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/test-opensuse-workflow.rb b/scripts/test-opensuse-workflow.rb
new file mode 100755
index 0000000..29f27d1
--- /dev/null
+++ b/scripts/test-opensuse-workflow.rb
@@ -0,0 +1,42 @@
+#!/usr/bin/env ruby
+require 'yaml'
+
+root = File.expand_path('..', __dir__)
+workflow = YAML.safe_load_file(File.join(root, '.github/workflows/publish-opensuse.yml'))
+release = YAML.safe_load_file(File.join(root, '.github/workflows/release.yml'))
+events = workflow['on'] || workflow[true]
+check = ->(condition, message) { raise message unless condition }
+check.call(!events.key?('push') && !events.key?('pull_request'), 'openSUSE publication must not run on arbitrary source changes')
+check.call(events.key?('workflow_call') && events.key?('workflow_dispatch'), 'release and operator entrypoints required')
+check.call(events.fetch('schedule').any? { |item| item['cron'] == '17 6 * * 1' }, 'weekly metadata refresh required')
+check.call(events.dig('workflow_dispatch', 'inputs', 'operation', 'options') == %w[publish refresh rollback], 'operator operations drifted')
+check.call(workflow.dig('permissions', 'contents') == 'read', 'GitHub access must stay read-only')
+check.call(workflow.dig('concurrency', 'cancel-in-progress') == false, 'active metadata promotion must not be cancelled')
+job = workflow.dig('jobs', 'publish')
+check.call(job['environment'] == 'packages-production', 'production secrets must be environment-scoped')
+check.call(job['if'].include?("vars.OPENSUSE_REPOSITORY_ENABLED == 'true'"), 'explicit repository enablement required')
+check.call(job['if'].include?('github.event.repository.default_branch'), 'operator runs must use reviewed default-branch code')
+check.call(job['if'] == job['if'].strip, 'job condition has literal trailing whitespace')
+check.call(job.dig('container', 'image').match?(/^opensuse\/tumbleweed@sha256:[a-f0-9]{64}$/), 'workflow must pin its Tumbleweed toolchain')
+publisher = job.fetch('steps').find { |step| step['run'] == 'bash scripts/publish-opensuse-workflow.sh' }
+check.call(publisher, 'workflow must use the reviewed publisher entrypoint')
+check.call(publisher.dig('env', 'OPERATION') == "${{ inputs.operation || 'refresh' }}", 'scheduled runs must refresh')
+%w[OPENSUSE_SIGNING_KEY OPENSUSE_SSH_PRIVATE_KEY OPENSUSE_SSH_KNOWN_HOSTS OPENSUSE_SIGNING_PASSPHRASE].each do |name|
+ check.call(publisher.dig('env', name) == "${{ secrets.#{name} }}", "#{name} must come from secrets")
+end
+caller = release.dig('jobs', 'publish-opensuse')
+check.call(caller['needs'] == 'publish-release', 'openSUSE publication must wait for existing release gates')
+check.call(caller['uses'] == './.github/workflows/publish-opensuse.yml', 'release must reuse the reviewed workflow')
+check.call(caller.dig('with', 'tag') == '${{ needs.publish-release.outputs.tag }}', 'use only verified release tag output')
+
+script = File.read(File.join(root, 'scripts/publish-opensuse-workflow.sh'))
+publish_index = script.index('scripts/publish-rpm-repository.py publish')
+%w[verify-release-signature.sh release-asset-manifest.mjs].each do |gate|
+ check.call(script.index(gate) && script.index(gate) < publish_index, "#{gate} must precede publication")
+end
+check.call(script.include?('--require-checksum --require-evidence'), 'public release inventory/evidence verification required')
+check.call(script.include?('--expected-revision "$expected"'), 'origin publication must use revision CAS')
+check.call(script.index('python3 scripts/verify-opensuse-public.py') > publish_index, 'activation requires verification of served bytes')
+check.call(script.include?('trap cleanup EXIT') && script.include?('unset OPENSUSE_SSH_PRIVATE_KEY'), 'private files/environment need cleanup')
+check.call(script.scan('--target "$target"').length >= 2, 'all repository operations must select the openSUSE target')
+puts 'openSUSE workflow contract passed: pinned toolchain, protected release ordering, secret transport, refresh and public proof.'
diff --git a/scripts/test-publish-rpm-repository.py b/scripts/test-publish-rpm-repository.py
index 9915166..4560c31 100644
--- a/scripts/test-publish-rpm-repository.py
+++ b/scripts/test-publish-rpm-repository.py
@@ -12,7 +12,9 @@
import argparse
import base64
import fcntl
+import functools
import hashlib
+import http.server
import importlib.util
import io
import json
@@ -25,6 +27,7 @@
import sys
import tarfile
import tempfile
+import threading
import time
import unittest
import urllib.error
@@ -35,6 +38,7 @@
SCRIPT = Path(__file__).with_name("publish-rpm-repository.py")
WITH_SSH = False
FPR = "A" * 40
+SUSE = "opensuse-tumbleweed-x86_64"
def load(name, path):
@@ -47,6 +51,11 @@ def load(name, path):
publisher = load("rpm_publisher", SCRIPT)
+class QuietHttpHandler(http.server.SimpleHTTPRequestHandler):
+ def log_message(self, *_args):
+ pass
+
+
def write_manifest(root, manifest):
manifest.pop("revision", None)
manifest["revision"] = hashlib.sha256(publisher.canonical(manifest)).hexdigest()
@@ -54,9 +63,11 @@ def write_manifest(root, manifest):
return manifest
-def fixture(root, version="1.0.0", created=1, package_bytes=None):
+def fixture(root, version="1.0.0", created=1, package_bytes=None,
+ target=publisher.DEFAULT_TARGET):
+ target, config, _package_path = publisher.target_config(target)
root.mkdir()
- package = f"packages/loopwire-{version}-1.fc44.x86_64.rpm"
+ package = f"packages/loopwire-{version}-{config['packageRelease']}.x86_64.rpm"
files = {
package: package_bytes or f"rpm package {version}".encode(),
f"keys/{FPR}.asc": b"synthetic public key",
@@ -68,33 +79,36 @@ def fixture(root, version="1.0.0", created=1, package_bytes=None):
files[f"repodata/{hashlib.sha256(data).hexdigest()}-{kind}.xml.gz"] = data
entries = []
for path, data in sorted(files.items()):
- target = root / path
- target.parent.mkdir(parents=True, exist_ok=True)
- target.write_bytes(data)
+ candidate_file = root / path
+ candidate_file.parent.mkdir(parents=True, exist_ok=True)
+ candidate_file.write_bytes(data)
entries.append({
"path": path,
- "kind": publisher.classify(path),
+ "kind": publisher.classify(path, target),
"size": len(data),
"sha256": hashlib.sha256(data).hexdigest(),
})
package_data = files[package]
+ package_record = {
+ "name": "loopwire",
+ "version": version,
+ "release": config["packageRelease"],
+ "architecture": "x86_64",
+ "path": package,
+ "sourceReleaseSha256": "1" * 64,
+ "distributedSha256": hashlib.sha256(package_data).hexdigest(),
+ "size": len(package_data),
+ }
+ if config["sourceRevision"]:
+ package_record["sourceRevision"] = "2" * 40
manifest = {
"schema": publisher.SCHEMA,
"schemaVersion": 1,
"createdAt": created,
"validUntil": created + 2592000,
"signingFingerprint": FPR,
- "target": publisher.TARGET.copy(),
- "packages": [{
- "name": "loopwire",
- "version": version,
- "release": "1.fc44",
- "architecture": "x86_64",
- "path": package,
- "sourceReleaseSha256": "1" * 64,
- "distributedSha256": hashlib.sha256(package_data).hexdigest(),
- "size": len(package_data),
- }],
+ "target": config["manifest"].copy(),
+ "packages": [package_record],
"files": entries,
}
return json.loads(json.dumps(write_manifest(root, manifest)))
@@ -405,7 +419,7 @@ def interrupt(label):
known_hosts=None, action="recover", dry_run=True,
allow_expired=False)
- def verify(_root, _key, _fingerprint, historical=False):
+ def verify(_root, _key, _fingerprint, historical=False, target=publisher.DEFAULT_TARGET):
if not historical:
raise publisher.PublicationError("expired repository")
return manifest
@@ -420,6 +434,209 @@ def verify(_root, _key, _fingerprint, historical=False):
completed = publisher.run(base)
self.assertTrue(completed["requiresRefresh"])
self.assertIn("Immediately", completed["nextAction"])
+ self.assertIn("DNF", completed["nextAction"])
+
+
+class TargetIsolationTests(unittest.TestCase):
+ def setUp(self):
+ self.temporary = tempfile.TemporaryDirectory(prefix="loopwire-rpm-target-tests-")
+ self.directory = Path(self.temporary.name)
+ self.root = self.directory / "origin"
+ self.fedora = self.directory / "fedora"
+ self.opensuse = self.directory / "opensuse"
+ self.opensuse_upgrade = self.directory / "opensuse-upgrade"
+ self.fedora_manifest = fixture(self.fedora)
+ self.opensuse_manifest = fixture(self.opensuse, target=SUSE)
+ self.opensuse_upgrade_manifest = fixture(
+ self.opensuse_upgrade, "1.1.0", 2, target=SUSE,
+ )
+
+ def tearDown(self):
+ self.temporary.cleanup()
+
+ def test_target_validation_happens_before_origin_write(self):
+ with self.assertRaisesRegex(publisher.PublicationError, "Fedora 44"):
+ publisher.publish_at(self.root, self.opensuse, FPR, "empty")
+ self.assertFalse(self.root.exists())
+ with self.assertRaisesRegex(publisher.PublicationError, "openSUSE"):
+ publisher.publish_at(self.root, self.fedora, FPR, "empty", SUSE)
+ self.assertFalse(self.root.exists())
+ manifest = json.loads((self.opensuse / publisher.MANIFEST).read_text())
+ manifest["packages"][0]["sourceRevision"] = "not-a-commit"
+ write_manifest(self.opensuse, manifest)
+ with self.assertRaisesRegex(publisher.PublicationError, "source revision"):
+ publisher.publish_at(self.root, self.opensuse, FPR, "empty", SUSE)
+ self.assertFalse(self.root.exists())
+
+ def test_independent_public_state_snapshot_lock_cas_and_idempotence(self):
+ publisher.publish_at(self.root, self.fedora, FPR, "empty")
+ result = publisher.publish_at(self.root, self.opensuse, FPR, "empty", SUSE)
+ self.assertEqual(result["target"], publisher.TARGETS[SUSE]["manifest"])
+ self.assertEqual(
+ publisher.state(self.root, "current")["revision"],
+ self.fedora_manifest["revision"],
+ )
+ self.assertEqual(
+ publisher.state(self.root, "current", SUSE)["revision"],
+ self.opensuse_manifest["revision"],
+ )
+ self.assertTrue((self.root / "snapshots" / self.fedora_manifest["revision"]).is_dir())
+ self.assertTrue((publisher.private_channel(self.root, SUSE) / "snapshots"
+ / self.opensuse_manifest["revision"]).is_dir())
+ self.assertTrue((publisher.public_channel(self.root) / "repodata/repomd.xml").is_file())
+ self.assertTrue((publisher.public_channel(self.root, SUSE)
+ / "repodata/repomd.xml").is_file())
+ self.assertEqual(
+ publisher.publish_at(self.root, self.opensuse, FPR, "empty", SUSE)["status"],
+ "unchanged",
+ )
+ with self.assertRaisesRegex(publisher.PublicationError, "compare-and-swap"):
+ publisher.publish_at(
+ self.root, self.opensuse_upgrade, FPR,
+ self.fedora_manifest["revision"], SUSE,
+ )
+ self.assertEqual(
+ publisher.state(self.root, "current", SUSE)["revision"],
+ self.opensuse_manifest["revision"],
+ )
+
+ # A held Fedora writer lock does not serialize the disjoint openSUSE
+ # namespace; openSUSE still uses its own exclusive writer lock.
+ with publisher.locked(self.root, create=True):
+ publisher.publish_at(
+ self.root, self.opensuse_upgrade, FPR,
+ self.opensuse_manifest["revision"], SUSE,
+ )
+ with publisher.locked(self.root, create=True, target=SUSE):
+ with self.assertRaisesRegex(publisher.PublicationError, "locked"):
+ publisher.publish_at(
+ self.root, self.opensuse, FPR,
+ self.opensuse_upgrade_manifest["revision"], SUSE,
+ )
+
+ def test_empty_opensuse_fetch_does_not_create_state_beside_fedora(self):
+ publisher.publish_at(self.root, self.fedora, FPR, "empty")
+ with self.assertRaises(publisher.EmptyRepository):
+ with publisher.selected_snapshot(
+ self.root, FPR, target=SUSE):
+ pass
+ self.assertFalse(publisher.private_channel(self.root, SUSE).exists())
+ self.assertEqual(
+ publisher.state(self.root, "current")["revision"],
+ self.fedora_manifest["revision"],
+ )
+
+ def test_opensuse_retention_collision_and_explicit_rollback(self):
+ publisher.publish_at(self.root, self.opensuse, FPR, "empty", SUSE)
+ publisher.publish_at(
+ self.root, self.opensuse_upgrade, FPR,
+ self.opensuse_manifest["revision"], SUSE,
+ )
+ channel = publisher.public_channel(self.root, SUSE)
+ old_package = self.opensuse_manifest["packages"][0]["path"]
+ new_package = self.opensuse_upgrade_manifest["packages"][0]["path"]
+ self.assertTrue((channel / old_package).is_file())
+ self.assertTrue((channel / new_package).is_file())
+
+ collision = self.directory / "collision"
+ collision_manifest = fixture(
+ collision, "1.1.0", 3, b"changed bytes at an immutable openSUSE URL",
+ target=SUSE,
+ )
+ with self.assertRaisesRegex(publisher.PublicationError, "immutable URL collision"):
+ publisher.publish_at(
+ self.root, collision, FPR,
+ self.opensuse_upgrade_manifest["revision"], SUSE,
+ )
+ self.assertFalse((publisher.private_channel(self.root, SUSE) / "snapshots"
+ / collision_manifest["revision"]).exists())
+
+ rollback = self.directory / "rollback"
+ rollback_manifest = fixture(rollback, "1.0.0", 4, target=SUSE)
+ publisher.publish_at(
+ self.root, rollback, FPR,
+ self.opensuse_upgrade_manifest["revision"], SUSE,
+ )
+ self.assertEqual(
+ publisher.state(self.root, "current", SUSE)["revision"],
+ rollback_manifest["revision"],
+ )
+ self.assertTrue((channel / new_package).is_file(),
+ "rollback must retain newer immutable package URLs")
+
+ def test_every_opensuse_checkpoint_recovers_without_fedora_state(self):
+ checkpoints = ("journal", "immutable", "signature", "committed", "manifest", "current")
+ for index, checkpoint in enumerate(checkpoints):
+ with self.subTest(checkpoint=checkpoint):
+ root = self.directory / f"interrupted-{index}"
+
+ def interrupt(label):
+ if label == checkpoint:
+ raise InterruptedError("openSUSE publication interrupted")
+
+ with mock.patch.object(publisher, "_checkpoint", side_effect=interrupt):
+ with self.assertRaises(InterruptedError):
+ publisher.publish_at(root, self.opensuse, FPR, "empty", SUSE)
+ self.assertIsNone(publisher.state(root, "current"))
+ self.assertEqual(
+ publisher.state(root, "pending", SUSE)["revision"],
+ self.opensuse_manifest["revision"],
+ )
+ publisher.recover_at(root, FPR, self.opensuse_manifest["revision"], SUSE)
+ self.assertEqual(
+ publisher.state(root, "current", SUSE)["revision"],
+ self.opensuse_manifest["revision"],
+ )
+ self.assertIsNone(publisher.state(root, "pending", SUSE))
+
+ def test_opensuse_private_paths_ignore_restrictive_umask(self):
+ previous_umask = os.umask(0o077)
+ try:
+ publisher.publish_at(self.root, self.opensuse, FPR, "empty", SUSE)
+ finally:
+ os.umask(previous_umask)
+ private = publisher.private_channel(self.root, SUSE)
+ self.assertEqual(stat.S_IMODE((self.root / "channels").stat().st_mode), 0o700)
+ for path in (private, *private.rglob("*")):
+ expected = 0o700 if path.is_dir() else 0o600
+ self.assertEqual(stat.S_IMODE(path.stat().st_mode), expected, str(path))
+ public = publisher.public_channel(self.root, SUSE)
+ for path in (public, *public.rglob("*")):
+ expected = 0o755 if path.is_dir() else 0o644
+ self.assertEqual(stat.S_IMODE(path.stat().st_mode), expected, str(path))
+
+ def test_expired_opensuse_recovery_names_zypper_refresh_boundary(self):
+ expired = self.directory / "opensuse-expired"
+ manifest = fixture(
+ expired, "2.0.0", int(time.time()) - 2592001, target=SUSE,
+ )
+
+ def interrupt(label):
+ if label == "journal":
+ raise InterruptedError("expired openSUSE pending journal")
+
+ with mock.patch.object(publisher, "_checkpoint", side_effect=interrupt):
+ with self.assertRaises(InterruptedError):
+ publisher.publish_at(self.root, expired, FPR, "empty", SUSE)
+ key = self.directory / "opensuse-key.asc"
+ key.write_text("synthetic", encoding="utf-8")
+ args = argparse.Namespace(
+ root=str(self.root), public_key=key, fingerprint=FPR,
+ target=SUSE, ssh=None, ssh_port=None, identity_file=None,
+ known_hosts=None, action="recover", dry_run=False,
+ allow_expired=True,
+ )
+
+ def verify(_root, _key, _fingerprint, historical=False, target=None):
+ self.assertEqual(target, SUSE)
+ self.assertTrue(historical)
+ return manifest
+
+ with mock.patch.object(publisher, "verify_signed", side_effect=verify):
+ completed = publisher.run(args)
+ self.assertTrue(completed["requiresRefresh"])
+ self.assertIn("Zypper/libzypp", completed["nextAction"])
+ self.assertNotIn("DNF", completed["nextAction"])
class SignedDnfCommitTests(unittest.TestCase):
@@ -613,6 +830,190 @@ def test_signed_cli_publish_fetch_idempotence_and_retained_revision(self):
(self.first / publisher.MANIFEST).read_bytes())
+class SignedZypperCommitTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ if not WITH_SSH:
+ raise unittest.SkipTest("runs with --with-ssh in the pinned openSUSE tools container")
+ fixtures = load(
+ "opensuse_repository_test_fixtures",
+ SCRIPT.with_name("test-rpm-repository.py"),
+ ).OpenSUSERepositoryTests
+ fixtures.setUpClass()
+ cls.fixtures = fixtures
+ cls.directory = fixtures.root / "publisher-integration"
+ cls.directory.mkdir()
+ cls.gnupg = fixtures.gnupg
+ cls.fingerprint = fixtures.fingerprint
+ cls.public_key = fixtures.key
+ cls.date = fixtures.date
+ cls.first = fixtures.base
+ cls.second = cls.directory / "candidate-opensuse-1.1.0"
+ fixtures.build(
+ fixtures.release2, "1.1.0", cls.second,
+ "--previous", cls.first, "--date", str(cls.date + 1),
+ )
+ cls.rollback = cls.directory / "candidate-rollback"
+ cls.shell(
+ sys.executable, SCRIPT.with_name("rpm-repository.py"), "rollback",
+ "--repository", cls.first, "--target", SUSE,
+ "--output", cls.rollback, "--signing-key", cls.fingerprint,
+ "--gnupg-home", cls.gnupg, "--date", str(cls.date + 2),
+ "--valid-for-days", "30",
+ )
+ cls.one = json.loads((cls.first / publisher.MANIFEST).read_text(encoding="utf-8"))
+ cls.two = json.loads((cls.second / publisher.MANIFEST).read_text(encoding="utf-8"))
+ cls.rolled = json.loads((cls.rollback / publisher.MANIFEST).read_text(encoding="utf-8"))
+
+ @classmethod
+ def tearDownClass(cls):
+ if hasattr(cls, "fixtures"):
+ cls.fixtures.tearDownClass()
+
+ @classmethod
+ def shell(cls, *command, cwd=None, ok=True):
+ result = subprocess.run(
+ list(map(str, command)), cwd=cwd, capture_output=True,
+ text=True, check=False,
+ )
+ if ok and result.returncode != 0:
+ raise AssertionError(
+ "command failed: " + " ".join(map(str, command))
+ + "\n" + result.stdout + result.stderr
+ )
+ return result
+
+ def setUp(self):
+ self.case_dir = self.directory / self.id().split(".")[-1]
+ self.case_dir.mkdir()
+ self.root = self.case_dir / "origin"
+
+ def publisher_cli(self, action, *extra, ok=True):
+ result = self.shell(
+ sys.executable, SCRIPT, action, "--target", SUSE,
+ "--root", self.root, "--public-key", self.public_key,
+ "--fingerprint", self.fingerprint, *extra, ok=False,
+ )
+ if ok:
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+ return result
+
+ def zypper(self, label, command, base_url=None):
+ state = self.case_dir / f"zypper-{label}"
+ repos = state / "repos"
+ for path in (state, repos, state / "cache", state / "raw", state / "solv", state / "packages"):
+ path.mkdir(exist_ok=True)
+ channel = publisher.public_channel(self.root, SUSE)
+ base_url = base_url or f"file://{channel}/"
+ key_url = base_url + f"keys/{self.fingerprint}.asc"
+ (repos / "loopwire.repo").write_text(
+ "[loopwire]\nname=Loopwire test\nenabled=1\nautorefresh=0\ntype=rpm-md\n"
+ f"baseurl={base_url}\n"
+ f"gpgkey={key_url}\n"
+ "gpgcheck=1\nrepo_gpgcheck=1\npkg_gpgcheck=1\n",
+ encoding="utf-8",
+ )
+ args = [
+ "zypper", "--non-interactive", "--gpg-auto-import-keys",
+ "--disable-system-resolvables", "--reposd-dir", repos,
+ "--cache-dir", state / "cache", "--raw-cache-dir", state / "raw",
+ "--solv-cache-dir", state / "solv", "--pkg-cache-dir", state / "packages",
+ ]
+ return self.shell(*args, *command, ok=False)
+
+ def test_signed_cli_publish_fetch_upgrade_retention_and_rollback(self):
+ dry = self.publisher_cli(
+ "publish", "--repository", self.first,
+ "--expected-revision", "empty", "--dry-run",
+ )
+ self.assertEqual(json.loads(dry.stdout)["status"], "validated")
+ self.assertFalse(self.root.exists())
+ self.publisher_cli(
+ "publish", "--repository", self.first, "--expected-revision", "empty",
+ )
+ fetched = self.case_dir / "fetched"
+ self.publisher_cli("fetch", "--output", fetched)
+ self.assertEqual((fetched / publisher.MANIFEST).read_bytes(),
+ (self.first / publisher.MANIFEST).read_bytes())
+ self.publisher_cli(
+ "publish", "--repository", self.second,
+ "--expected-revision", self.one["revision"],
+ )
+ self.publisher_cli(
+ "publish", "--repository", self.rollback,
+ "--expected-revision", self.two["revision"],
+ )
+ channel = publisher.public_channel(self.root, SUSE)
+ for manifest in (self.one, self.two):
+ for package in manifest["packages"]:
+ self.assertTrue((channel / package["path"]).is_file())
+ self.assertEqual(
+ publisher.state(self.root, "current", SUSE)["revision"],
+ self.rolled["revision"],
+ )
+
+ def test_real_zypper_rejects_mixed_metadata_then_accepts_recovery_and_rollback(self):
+ publisher.publish_at(self.root, self.first, self.fingerprint, "empty", SUSE)
+ handler = functools.partial(
+ QuietHttpHandler, directory=str(self.root / "public"),
+ )
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ base_url = (
+ f"http://127.0.0.1:{server.server_port}/opensuse/tumbleweed/x86_64/"
+ )
+ try:
+ refreshed = self.zypper("initial", ["refresh"], base_url)
+ self.assertEqual(refreshed.returncode, 0, refreshed.stdout + refreshed.stderr)
+ searched = self.zypper(
+ "initial-search", ["search", "--details", "loopwire"], base_url,
+ )
+ self.assertEqual(searched.returncode, 0, searched.stdout + searched.stderr)
+ self.assertIn("1.0.0", searched.stdout)
+
+ def interrupt(label):
+ if label == "signature":
+ raise InterruptedError("leave new signature with old repomd.xml")
+
+ with mock.patch.object(publisher, "_checkpoint", side_effect=interrupt):
+ with self.assertRaises(InterruptedError):
+ publisher.publish_at(
+ self.root, self.second, self.fingerprint,
+ self.one["revision"], SUSE,
+ )
+ mixed = self.zypper("mixed", ["refresh"], base_url)
+ self.assertNotEqual(mixed.returncode, 0, mixed.stdout + mixed.stderr)
+ self.assertRegex(
+ mixed.stdout + mixed.stderr,
+ r"(?i)(signature|verification).*(fail|invalid)",
+ )
+
+ publisher.recover_at(self.root, self.fingerprint, self.two["revision"], SUSE)
+ recovered = self.zypper("recovered", ["refresh"], base_url)
+ self.assertEqual(recovered.returncode, 0, recovered.stdout + recovered.stderr)
+ searched = self.zypper(
+ "recovered-search", ["search", "--details", "loopwire"], base_url,
+ )
+ self.assertIn("1.1.0", searched.stdout)
+
+ publisher.publish_at(
+ self.root, self.rollback, self.fingerprint,
+ self.two["revision"], SUSE,
+ )
+ rolled = self.zypper("rollback", ["refresh"], base_url)
+ self.assertEqual(rolled.returncode, 0, rolled.stdout + rolled.stderr)
+ searched = self.zypper(
+ "rollback-search", ["search", "--details", "loopwire"], base_url,
+ )
+ self.assertIn("1.0.0", searched.stdout)
+ self.assertNotIn("1.1.0", searched.stdout)
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join()
+
+
class SshPublicationTests(unittest.TestCase):
def test_actual_ssh_publish_fetch_and_recover_without_remote_gpg(self):
if not WITH_SSH:
@@ -633,6 +1034,10 @@ def test_actual_ssh_publish_fetch_and_recover_without_remote_gpg(self):
second = directory / "second"
one = fixture(first)
two = fixture(second, "1.1.0", 2)
+ opensuse_first = directory / "opensuse-first"
+ opensuse_second = directory / "opensuse-second"
+ opensuse_one = fixture(opensuse_first, target=SUSE)
+ opensuse_two = fixture(opensuse_second, "1.1.0", 2, target=SUSE)
origin = directory / "origin"
identity = directory / "identity"
host_key = directory / "host-key"
@@ -647,13 +1052,20 @@ def test_actual_ssh_publish_fetch_and_recover_without_remote_gpg(self):
remote_bin = directory / "remote-bin"
remote_bin.mkdir()
(remote_bin / "python3").symlink_to(sys.executable)
+ force_command = directory / "force-command"
+ force_command.write_text(
+ "#!/bin/sh\n"
+ f"PATH={remote_bin} exec /bin/sh -c \"$SSH_ORIGINAL_COMMAND\"\n",
+ encoding="utf-8",
+ )
+ force_command.chmod(0o700)
configuration = directory / "sshd.conf"
configuration.write_text(
f"Port {port}\nListenAddress 127.0.0.1\nHostKey {host_key}\n"
f"PidFile {directory / 'sshd.pid'}\nAuthorizedKeysFile {identity}.pub\n"
"PermitRootLogin prohibit-password\nPasswordAuthentication no\n"
"KbdInteractiveAuthentication no\nUsePAM no\nStrictModes no\nAllowUsers root\n"
- f"LogLevel ERROR\nSetEnv PATH={remote_bin}\n")
+ f"LogLevel ERROR\nForceCommand {force_command}\n")
Path("/run/sshd").mkdir(exist_ok=True)
with (directory / "sshd.log").open("wb") as log:
server = subprocess.Popen([sshd, "-D", "-e", "-f", str(configuration)],
@@ -708,6 +1120,44 @@ def interrupt(label):
})
self.assertEqual(result["revision"], two["revision"])
self.assertIsNone(publisher.state(origin, "pending"))
+
+ result = publisher.remote_call(connection, {
+ "action": "publish", "root": str(origin), "target": SUSE,
+ "fingerprint": FPR, "expected": "empty",
+ }, repository=opensuse_first)
+ self.assertEqual(result["revision"], opensuse_one["revision"])
+ opensuse_fetched = directory / "opensuse-fetched"
+ opensuse_fetched.mkdir()
+ publisher.remote_call(connection, {
+ "action": "fetch", "root": str(origin), "target": SUSE,
+ "fingerprint": FPR, "revision": None,
+ }, output=opensuse_fetched)
+ self.assertEqual(
+ (opensuse_fetched / publisher.MANIFEST).read_bytes(),
+ (opensuse_first / publisher.MANIFEST).read_bytes(),
+ )
+ with mock.patch.object(publisher, "_checkpoint", side_effect=interrupt):
+ with self.assertRaises(InterruptedError):
+ publisher.publish_at(
+ origin, opensuse_second, FPR,
+ opensuse_one["revision"], SUSE,
+ )
+ opensuse_pending = directory / "opensuse-pending"
+ opensuse_pending.mkdir()
+ publisher.remote_call(connection, {
+ "action": "fetch-pending", "root": str(origin), "target": SUSE,
+ "fingerprint": FPR, "revision": None,
+ }, output=opensuse_pending)
+ result = publisher.remote_call(connection, {
+ "action": "recover", "root": str(origin), "target": SUSE,
+ "fingerprint": FPR, "revision": opensuse_two["revision"],
+ })
+ self.assertEqual(result["revision"], opensuse_two["revision"])
+ self.assertIsNone(publisher.state(origin, "pending", SUSE))
+ self.assertEqual(
+ publisher.state(origin, "current")["revision"], two["revision"],
+ "openSUSE SSH operations must not alter Fedora state",
+ )
known.write_text("", encoding="utf-8")
with self.assertRaisesRegex(publisher.PublicationError, "SSH"):
publisher.remote_call(connection, {
@@ -731,7 +1181,10 @@ def test_syntax_and_live_cache_headers(self):
origin = directory / "origin"
candidate = directory / "candidate"
manifest = fixture(candidate)
+ opensuse_candidate = directory / "opensuse-candidate"
+ opensuse_manifest = fixture(opensuse_candidate, target=SUSE)
publisher.publish_at(origin, candidate, FPR, "empty")
+ publisher.publish_at(origin, opensuse_candidate, FPR, "empty", SUSE)
snippet = directory / "nginx-rpm.conf"
snippet.write_text(
snippet_path.read_text(encoding="utf-8").replace(
@@ -753,7 +1206,8 @@ def test_syntax_and_live_cache_headers(self):
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
text=True)
try:
- base = f"http://127.0.0.1:{port}/fedora/44/x86_64/"
+ host = f"http://127.0.0.1:{port}/"
+ base = host + "fedora/44/x86_64/"
for _ in range(100):
try:
urllib.request.urlopen(base + "repodata/repomd.xml", timeout=0.1).close()
@@ -763,24 +1217,48 @@ def test_syntax_and_live_cache_headers(self):
self.fail(server.stderr.read())
time.sleep(0.02)
- def headers(path):
- with urllib.request.urlopen(base + path, timeout=2) as response:
+ def headers(base_url, path):
+ with urllib.request.urlopen(base_url + path, timeout=2) as response:
self.assertEqual(response.status, 200)
return response.headers
- self.assertIn("no-store", headers("repodata/repomd.xml")["Cache-Control"])
- self.assertIn("no-store", headers("repodata/repomd.xml.asc")["Cache-Control"])
+ self.assertIn("no-store", headers(base, "repodata/repomd.xml")["Cache-Control"])
+ self.assertIn("no-store", headers(base, "repodata/repomd.xml.asc")["Cache-Control"])
package = manifest["packages"][0]["path"]
- self.assertIn("immutable", headers(package)["Cache-Control"])
+ self.assertIn("immutable", headers(base, package)["Cache-Control"])
hashed = next(entry["path"] for entry in manifest["files"]
if entry["path"].endswith("primary.xml.gz"))
- self.assertIn("immutable", headers(hashed)["Cache-Control"])
+ self.assertIn("immutable", headers(base, hashed)["Cache-Control"])
with self.assertRaises(urllib.error.HTTPError) as missing:
urllib.request.urlopen(base +
"packages/loopwire-9.9.9-1.fc44.x86_64.rpm", timeout=2)
self.assertEqual(missing.exception.code, 404)
self.assertIn("no-store", missing.exception.headers["Cache-Control"])
missing.exception.close()
+
+ opensuse_base = host + "opensuse/tumbleweed/x86_64/"
+ self.assertIn("no-store", headers(
+ opensuse_base, "repodata/repomd.xml")["Cache-Control"])
+ self.assertIn("no-store", headers(
+ opensuse_base, "repodata/repomd.xml.asc")["Cache-Control"])
+ self.assertIn("immutable", headers(
+ opensuse_base,
+ opensuse_manifest["packages"][0]["path"],
+ )["Cache-Control"])
+ opensuse_hashed = next(
+ entry["path"] for entry in opensuse_manifest["files"]
+ if entry["path"].endswith("primary.xml.gz")
+ )
+ self.assertIn("immutable", headers(
+ opensuse_base, opensuse_hashed)["Cache-Control"])
+ with self.assertRaises(urllib.error.HTTPError) as opensuse_missing:
+ urllib.request.urlopen(
+ opensuse_base + "packages/loopwire-9.9.9-1.x86_64.rpm",
+ timeout=2,
+ )
+ self.assertEqual(opensuse_missing.exception.code, 404)
+ self.assertIn("no-store", opensuse_missing.exception.headers["Cache-Control"])
+ opensuse_missing.exception.close()
finally:
server.terminate()
server.wait(timeout=10)
diff --git a/scripts/test-rpm-repository.py b/scripts/test-rpm-repository.py
index 750f9fb..24cf511 100644
--- a/scripts/test-rpm-repository.py
+++ b/scripts/test-rpm-repository.py
@@ -43,6 +43,8 @@ def log_message(self, *_args):
class RepositoryTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
+ if not shutil.which("dnf"):
+ raise unittest.SkipTest("Fedora repository cases require DNF")
for tool in (
"createrepo_c", "dnf", "gpg", "gpgv", "openssl", "rpm", "rpmbuild", "rpmkeys", "rpmsign",
):
@@ -455,5 +457,418 @@ def test_symlink_hardlink_and_output_reuse_rejected(self):
).returncode, 0)
+class OpenSUSERepositoryTests(unittest.TestCase):
+ TARGET = "opensuse-tumbleweed-x86_64"
+
+ @classmethod
+ def setUpClass(cls):
+ if not shutil.which("zypper"):
+ raise unittest.SkipTest("openSUSE repository cases require Zypper")
+ for tool in (
+ "createrepo_c", "gpg", "gpgv", "openssl", "rpm", "rpmbuild", "rpmkeys", "rpmsign", "zypper",
+ ):
+ if not shutil.which(tool):
+ raise RuntimeError(f"{tool} required; run tests in Dockerfile.opensuse-rpm-tools")
+ cls.temporary = tempfile.TemporaryDirectory(prefix="loopwire-opensuse-repository-tests-")
+ cls.root = Path(cls.temporary.name)
+ cls.root.chmod(0o755)
+ cls.gnupg = cls.root / "gnupg"
+ cls.gnupg.mkdir(mode=0o700)
+ cls.fingerprints = []
+ for identity in ("Loopwire openSUSE Test", "Wrong openSUSE Test"):
+ run(
+ "gpg", "--homedir", cls.gnupg, "--batch", "--pinentry-mode", "loopback",
+ "--passphrase", "", "--quick-generate-key", identity, "rsa2048", "sign", "0",
+ )
+ listing = run("gpg", "--homedir", cls.gnupg, "--with-colons", "--list-keys", identity).stdout
+ cls.fingerprints.append(next(
+ line.split(":")[9] for line in listing.splitlines() if line.startswith("fpr:")
+ ))
+ cls.fingerprint, cls.wrong_fingerprint = cls.fingerprints
+ cls.key = cls.root / "repository.asc"
+ cls.key.write_text(run(
+ "gpg", "--homedir", cls.gnupg, "--armor", "--export", cls.fingerprint,
+ ).stdout)
+ cls.wrong_key = cls.root / "wrong.asc"
+ cls.wrong_key.write_text(run(
+ "gpg", "--homedir", cls.gnupg, "--armor", "--export", cls.wrong_fingerprint,
+ ).stdout)
+ cls.tampered_key = cls.root / "tampered.asc"
+ cls.tampered_key.write_text(cls.key.read_text().replace("A", "B", 1))
+ cls.release_private = cls.root / "release-private.pem"
+ cls.release_public = cls.root / "release-public.pem"
+ run(
+ "openssl", "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048",
+ "-out", cls.release_private,
+ )
+ run("openssl", "pkey", "-in", cls.release_private, "-pubout", "-out", cls.release_public)
+ cls.date = int(time.time())
+ cls.release1 = cls.make_release("1.0.0")
+ cls.release2 = cls.make_release("1.1.0")
+ cls.base = cls.root / "base"
+ cls.build(cls.release1, "1.0.0", cls.base)
+
+ @classmethod
+ def tearDownClass(cls):
+ run("gpgconf", "--homedir", cls.gnupg, "--kill", "gpg-agent", ok=False)
+ cls.temporary.cleanup()
+
+ @classmethod
+ def source_revision(cls, version, suffix=""):
+ return hashlib.sha1(f"loopwire:{version}:{suffix}".encode(), usedforsecurity=False).hexdigest()
+
+ @classmethod
+ def make_rpm(
+ cls, version, *, name="loopwire", release="1", architecture="x86_64", suffix="",
+ ):
+ fixture = cls.root / f"opensuse-rpm-{version}-{name}-{release}-{architecture}{suffix}"
+ top = fixture / "rpmbuild"
+ for directory in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"):
+ (top / directory).mkdir(parents=True)
+ spec = top / "SPECS/fixture.spec"
+ spec.write_text(
+ f"Name: {name}\nVersion: {version}\nRelease: {release}\n"
+ "Summary: Loopwire openSUSE repository fixture\nLicense: MIT\n"
+ f"BuildArch: {architecture}\nAutoReqProv: no\n\n"
+ "%description\nRepository fixture.\n\n%prep\n\n%build\n\n"
+ "%install\nmkdir -p %{buildroot}/usr/share/loopwire\n"
+ f"printf '%s\\n' '{version}{suffix}' > %{{buildroot}}/usr/share/loopwire/fixture\n\n"
+ "%files\n/usr/share/loopwire/fixture\n",
+ )
+ run(
+ "rpmbuild", "--define", f"_topdir {top}", "--define", "_buildhost fixture.invalid",
+ "--define", f"_source_date_epoch {cls.date}", "--define", "use_source_date_epoch_as_buildtime 1",
+ "-bb", spec,
+ )
+ packages = list((top / "RPMS").glob("**/*.rpm"))
+ if len(packages) != 1:
+ raise AssertionError(f"expected one openSUSE RPM fixture, got {packages}")
+ return packages[0]
+
+ @classmethod
+ def write_release_manifest(cls, release, version, revision, artifacts=None):
+ if artifacts is None:
+ names = [f"loopwire-{version}-1.x86_64.rpm", "loopwire-linux-x86_64.tar.gz"]
+ classifications = {
+ names[0]: ("native-rpm", "opensuse-tumbleweed", "x86_64"),
+ names[1]: ("portable-archive", "linux-generic", "x86_64"),
+ }
+ artifacts = [{
+ "name": name,
+ "kind": classifications[name][0],
+ "target": classifications[name][1],
+ "architecture": classifications[name][2],
+ "bytes": (release / name).stat().st_size,
+ "sha256": digest(release / name),
+ } for name in names]
+ manifest = {
+ "schema": "loopwire.release-assets.v1",
+ "release": {"tag": f"v{version}", "version": version, "gitHead": revision},
+ "artifacts": artifacts,
+ }
+ (release / "release-assets.json").write_text(json.dumps(manifest, indent=2) + "\n")
+
+ @classmethod
+ def sign_release(cls, release):
+ payloads = sorted(
+ path for path in release.iterdir() if path.name not in ("SHA256SUMS", "SHA256SUMS.sig")
+ )
+ (release / "SHA256SUMS").write_text("".join(
+ f"{digest(payload)} {payload.name}\n" for payload in payloads
+ ))
+ run(
+ "openssl", "dgst", "-sha256", "-sign", cls.release_private,
+ "-out", release / "SHA256SUMS.sig", release / "SHA256SUMS",
+ )
+
+ @classmethod
+ def make_release(
+ cls, version, *, name="loopwire", release_tag="1", architecture="x86_64", suffix="",
+ ):
+ directory = cls.root / f"opensuse-release-{version}-{name}-{release_tag}-{architecture}{suffix}"
+ directory.mkdir()
+ built = cls.make_rpm(
+ version, name=name, release=release_tag, architecture=architecture, suffix=suffix,
+ )
+ filename = f"loopwire-{version}-1.x86_64.rpm"
+ shutil.copyfile(built, directory / filename)
+ (directory / "loopwire-linux-x86_64.tar.gz").write_bytes(
+ f"portable release {version} {suffix}\n".encode()
+ )
+ cls.write_release_manifest(directory, version, cls.source_revision(version, suffix))
+ cls.sign_release(directory)
+ return directory
+
+ @classmethod
+ def build(cls, release, version, output, *extra, ok=True):
+ return run(
+ sys.executable, SCRIPT, "build", "--target", cls.TARGET,
+ "--release-dir", release, "--version", version, "--output", output,
+ "--signing-key", cls.fingerprint, "--gnupg-home", cls.gnupg,
+ "--release-public-key", cls.release_public, "--date", cls.date,
+ *extra, ok=ok,
+ )
+
+ def setUp(self):
+ self.case_dir = self.root / self.id().split(".")[-1]
+ self.case_dir.mkdir(mode=0o755)
+ self.repo = self.case_dir / "repository"
+ shutil.copytree(self.base, self.repo)
+
+ def reset_repo(self):
+ shutil.rmtree(self.repo)
+ shutil.copytree(self.base, self.repo)
+
+ def verify(self, *extra, ok=True, key=None, fingerprint=None, target=TARGET):
+ command = [
+ sys.executable, SCRIPT, "verify", "--repository", self.repo,
+ "--public-key", key or self.key, "--fingerprint", fingerprint or self.fingerprint,
+ ]
+ if target is not None:
+ command.extend(["--target", target])
+ return run(*command, *extra, ok=ok)
+
+ def rewrite_manifest(self, update_packages=False):
+ path = self.repo / "repository-manifest.json"
+ manifest = json.loads(path.read_text())
+ for entry in manifest["files"]:
+ file = self.repo / entry["path"]
+ if file.is_file():
+ entry.update(sha256=digest(file), size=file.stat().st_size)
+ if update_packages:
+ for package in manifest["packages"]:
+ file = self.repo / package["path"]
+ package.update(distributedSha256=digest(file), size=file.stat().st_size)
+ manifest.pop("revision", None)
+ manifest["revision"] = hashlib.sha256(json.dumps(
+ manifest, sort_keys=True, separators=(",", ":"),
+ ).encode()).hexdigest()
+ path.write_text(json.dumps(manifest))
+
+ def resign_plain_metadata(self):
+ repodata = self.repo / "repodata"
+ shutil.rmtree(repodata)
+ run(
+ "createrepo_c", "--quiet", "--no-database", "--checksum", "sha256",
+ "--repomd-checksum", "sha256", "--general-compress-type", "gz",
+ "--unique-md-filenames", self.repo,
+ )
+ run(
+ "gpg", "--homedir", self.gnupg, "--batch", "--yes", "--armor", "--detach-sign",
+ "--local-user", self.fingerprint, "--output", repodata / "repomd.xml.asc",
+ repodata / "repomd.xml",
+ )
+
+ def zypper(self, *, install=True, key=None):
+ requests = []
+
+ class RecordingHandler(QuietHandler):
+ def do_GET(handler_self):
+ requests.append(handler_self.path.split("?", 1)[0])
+ super().do_GET()
+
+ handler = functools.partial(RecordingHandler, directory=str(self.repo))
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ target_root = self.case_dir / f"zypper-{time.time_ns()}"
+ repos = target_root / "etc/zypp/repos.d"
+ keys = target_root / "etc/zypp/keys"
+ repos.mkdir(parents=True)
+ keys.mkdir(parents=True)
+ installed_key = keys / f"loopwire-repository-{self.fingerprint}.asc"
+ shutil.copyfile(key or self.key, installed_key)
+ (repos / "loopwire.repo").write_text(
+ "[loopwire]\nname=Loopwire openSUSE test\nenabled=1\nautorefresh=0\ntype=rpm-md\n"
+ f"baseurl=http://127.0.0.1:{server.server_port}/\n"
+ f"gpgkey={installed_key.resolve().as_uri()}\n"
+ "gpgcheck=1\nrepo_gpgcheck=1\npkg_gpgcheck=1\npriority=90\nkeeppackages=1\n"
+ )
+ base = ["zypper", "--root", target_root, "--non-interactive", "--gpg-auto-import-keys"]
+ try:
+ refresh = run(*base, "refresh", "--force", "loopwire", ok=False)
+ transaction = None
+ if install and refresh.returncode == 0:
+ transaction = run(
+ *base, "--no-refresh", "install", "--no-recommends", "--from", "loopwire",
+ "loopwire", ok=False,
+ )
+ return refresh, transaction, target_root, requests
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join()
+
+ def test_signed_release_provenance_and_strict_zypper_install(self):
+ summary = json.loads(self.verify().stdout)
+ self.assertEqual(summary["target"], {
+ "distribution": "opensuse", "release": "tumbleweed", "architecture": "x86_64",
+ })
+ package = summary["packages"][0]
+ source = self.release1 / "loopwire-1.0.0-1.x86_64.rpm"
+ self.assertEqual(package["sourceReleaseSha256"], digest(source))
+ self.assertEqual(package["sourceRevision"], self.source_revision("1.0.0"))
+ self.assertNotEqual(package["distributedSha256"], digest(source))
+ refresh, transaction, target_root, requests = self.zypper()
+ self.assertEqual(refresh.returncode, 0, refresh.stdout + refresh.stderr)
+ self.assertIsNotNone(transaction)
+ self.assertEqual(transaction.returncode, 0, transaction.stdout + transaction.stderr)
+ self.assertEqual(run("rpm", "--root", target_root, "-q", "--queryformat", "%{EVR}", "loopwire").stdout,
+ "1.0.0-1")
+ self.assertIn("/repodata/repomd.xml.asc", requests)
+ self.assertNotIn("/repodata/repomd.xml.key", requests)
+
+ def test_five_required_release_inputs_and_manifest_tampering_rejected(self):
+ self.assertEqual({path.name for path in self.release1.iterdir()}, {
+ "loopwire-1.0.0-1.x86_64.rpm", "loopwire-linux-x86_64.tar.gz",
+ "release-assets.json", "SHA256SUMS", "SHA256SUMS.sig",
+ })
+ for missing in ("loopwire-linux-x86_64.tar.gz", "release-assets.json", "SHA256SUMS.sig"):
+ release = self.case_dir / f"missing-{missing.replace('.', '-')}"
+ shutil.copytree(self.release1, release)
+ (release / missing).unlink()
+ self.assertNotEqual(self.build(release, "1.0.0", self.case_dir / f"out-{missing}",
+ ok=False).returncode, 0)
+ for filename in (
+ "loopwire-1.0.0-1.x86_64.rpm", "loopwire-linux-x86_64.tar.gz",
+ "release-assets.json", "SHA256SUMS", "SHA256SUMS.sig",
+ ):
+ with self.subTest(tampered=filename):
+ release = self.case_dir / f"tampered-{filename.replace('.', '-')}"
+ shutil.copytree(self.release1, release)
+ with (release / filename).open("ab") as stream:
+ stream.write(b"tampered")
+ self.assertNotEqual(self.build(
+ release, "1.0.0", self.case_dir / f"tamper-out-{filename}", ok=False,
+ ).returncode, 0)
+ release = self.case_dir / "wrong-manifest"
+ shutil.copytree(self.release1, release)
+ manifest = json.loads((release / "release-assets.json").read_text())
+ manifest["release"]["gitHead"] = "not-a-commit"
+ (release / "release-assets.json").write_text(json.dumps(manifest))
+ self.sign_release(release)
+ self.assertNotEqual(self.build(release, "1.0.0", self.case_dir / "wrong-commit",
+ ok=False).returncode, 0)
+ manifest["release"]["gitHead"] = self.source_revision("1.0.0")
+ manifest["artifacts"][0]["target"] = "fedora-44"
+ (release / "release-assets.json").write_text(json.dumps(manifest))
+ self.sign_release(release)
+ self.assertNotEqual(self.build(release, "1.0.0", self.case_dir / "wrong-target",
+ ok=False).returncode, 0)
+
+ def test_identity_target_version_and_same_version_repack_rejected(self):
+ variants = [
+ (dict(name="other"), "name"),
+ (dict(architecture="noarch"), "architecture"),
+ (dict(release_tag="2"), "release"),
+ ]
+ for options, suffix in variants:
+ release = self.make_release("1.2.0", suffix=suffix, **options)
+ self.assertNotEqual(self.build(release, "1.2.0", self.case_dir / suffix,
+ ok=False).returncode, 0)
+ self.assertNotEqual(self.build(self.release1, "1.0.1", self.case_dir / "wrong-version",
+ ok=False).returncode, 0)
+ self.assertNotEqual(self.verify("--target", "fedora-44-x86_64", target=None,
+ ok=False).returncode, 0)
+ repack = self.make_release("1.0.0", suffix="repack")
+ self.assertNotEqual(self.build(repack, "1.0.0", self.case_dir / "repack",
+ "--previous", self.base, ok=False).returncode, 0)
+
+ def test_deterministic_retention_downgrade_and_fresh_rollback(self):
+ second = self.case_dir / "second"
+ self.build(self.release1, "1.0.0", second)
+ self.assertEqual((second / "repository-manifest.json").read_bytes(),
+ (self.base / "repository-manifest.json").read_bytes())
+ upgraded = self.case_dir / "upgraded"
+ self.build(self.release2, "1.1.0", upgraded, "--previous", self.base)
+ first = json.loads((self.base / "repository-manifest.json").read_text())
+ latest = json.loads((upgraded / "repository-manifest.json").read_text())
+ for entry in first["files"]:
+ if entry["kind"] == "immutable":
+ self.assertEqual(digest(upgraded / entry["path"]), entry["sha256"])
+ self.assertEqual([package["version"] for package in latest["packages"]], ["1.0.0", "1.1.0"])
+ self.assertNotEqual(self.build(self.release1, "1.0.0", self.case_dir / "downgrade",
+ "--previous", upgraded, ok=False).returncode, 0)
+ rollback = self.case_dir / "rollback"
+ run(
+ sys.executable, SCRIPT, "rollback", "--repository", self.base, "--output", rollback,
+ "--signing-key", self.fingerprint, "--gnupg-home", self.gnupg,
+ "--date", self.date + 60,
+ )
+ rolled = json.loads((rollback / "repository-manifest.json").read_text())
+ self.assertEqual(rolled["packages"], first["packages"])
+ self.assertEqual(rolled["target"], first["target"])
+ self.assertNotEqual(rolled["revision"], first["revision"])
+ run(
+ sys.executable, SCRIPT, "verify", "--repository", rollback, "--public-key", self.key,
+ "--fingerprint", self.fingerprint, "--target", self.TARGET, "--now", self.date + 60,
+ )
+
+ def test_zypper_rejects_wrong_or_tampered_key_and_metadata(self):
+ for key in (self.wrong_key, self.tampered_key):
+ refresh, _transaction, _root, _requests = self.zypper(install=False, key=key)
+ self.assertNotEqual(refresh.returncode, 0, refresh.stdout + refresh.stderr)
+ repomd = self.repo / "repodata/repomd.xml"
+ repomd.write_text(repomd.read_text().replace("openSUSE Tumbleweed", "forged Tumbleweed"))
+ self.rewrite_manifest()
+ self.assertNotEqual(self.verify(ok=False).returncode, 0)
+ refresh, _transaction, _root, _requests = self.zypper(install=False)
+ self.assertNotEqual(refresh.returncode, 0, refresh.stdout + refresh.stderr)
+ shutil.copytree(self.base, self.repo, dirs_exist_ok=True)
+ (self.repo / "repodata/repomd.xml.asc").unlink()
+ refresh, _transaction, _root, _requests = self.zypper(install=False)
+ self.assertNotEqual(refresh.returncode, 0, refresh.stdout + refresh.stderr)
+
+ def test_zypper_and_verifier_reject_unsigned_or_tampered_rpm(self):
+ package = next(self.repo.glob("packages/*.rpm"))
+ run("rpmsign", "--delsign", package)
+ self.rewrite_manifest(update_packages=True)
+ self.assertNotEqual(self.verify(ok=False).returncode, 0)
+ self.resign_plain_metadata()
+ refresh, transaction, _root, _requests = self.zypper()
+ self.assertEqual(refresh.returncode, 0, refresh.stdout + refresh.stderr)
+ self.assertIsNotNone(transaction)
+ self.assertNotEqual(transaction.returncode, 0, transaction.stdout + transaction.stderr)
+ self.reset_repo()
+ package = next(self.repo.glob("packages/*.rpm"))
+ package.write_bytes(package.read_bytes() + b"tampered")
+ self.rewrite_manifest(update_packages=True)
+ self.assertNotEqual(self.verify(ok=False).returncode, 0)
+ refresh, transaction, _root, _requests = self.zypper()
+ self.assertEqual(refresh.returncode, 0, refresh.stdout + refresh.stderr)
+ self.assertIsNotNone(transaction)
+ self.assertNotEqual(transaction.returncode, 0, transaction.stdout + transaction.stderr)
+ self.reset_repo()
+ package = next(self.repo.glob("packages/*.rpm"))
+ run(
+ "rpmsign", "--resign", "--define", f"_gpg_name {self.wrong_fingerprint}",
+ "--define", f"_gpg_path {self.gnupg}", package,
+ )
+ self.rewrite_manifest(update_packages=True)
+ self.assertNotEqual(self.verify(ok=False).returncode, 0)
+ self.resign_plain_metadata()
+ refresh, transaction, _root, _requests = self.zypper()
+ self.assertEqual(refresh.returncode, 0, refresh.stdout + refresh.stderr)
+ self.assertIsNotNone(transaction)
+ self.assertNotEqual(transaction.returncode, 0, transaction.stdout + transaction.stderr)
+
+ def test_wrong_signer_expiry_and_repository_shape_rejected(self):
+ self.assertNotEqual(self.verify(key=self.wrong_key, ok=False).returncode, 0)
+ self.assertNotEqual(self.verify(fingerprint=self.wrong_fingerprint, ok=False).returncode, 0)
+ self.assertNotEqual(self.verify("--now", self.date + 31 * 86400, ok=False).returncode, 0)
+ (self.repo / "extra").write_text("unlisted")
+ self.assertNotEqual(self.verify(ok=False).returncode, 0)
+ (self.repo / "extra").unlink()
+ package = next(self.repo.glob("packages/*.rpm"))
+ original = package.read_bytes()
+ package.unlink()
+ package.symlink_to(self.base / package.relative_to(self.repo))
+ self.assertNotEqual(self.verify(ok=False).returncode, 0)
+ package.unlink()
+ package.write_bytes(original)
+ os.link(package, self.case_dir / "hardlink")
+ self.assertNotEqual(self.verify(ok=False).returncode, 0)
+
+
if __name__ == "__main__":
unittest.main(verbosity=2)
diff --git a/scripts/verify-docs.sh b/scripts/verify-docs.sh
index 298475c..b1327b2 100644
--- a/scripts/verify-docs.sh
+++ b/scripts/verify-docs.sh
@@ -8,6 +8,7 @@ required_files=(
"apps/docs/docs/guide/install.md"
"apps/docs/docs/guide/apt-repository.md"
"apps/docs/docs/guide/fedora-repository.md"
+ "apps/docs/docs/guide/opensuse-repository.md"
"apps/docs/docs/guide/basic-usage.md"
"apps/docs/docs/guide/start-on-boot.md"
"apps/docs/docs/guide/backends.md"
@@ -21,6 +22,7 @@ required_files=(
"apps/docs/docs/developer/release.md"
"apps/docs/docs/developer/apt-repository.md"
"apps/docs/docs/developer/fedora-repository.md"
+ "apps/docs/docs/developer/opensuse-repository.md"
"apps/docs/docs/developer/release-notes.md"
"apps/docs/docs/release-notes/0.1.0.md"
"apps/docs/docs/release-notes/unreleased.md"
@@ -71,6 +73,8 @@ assert_contains "apps/docs/docs/.vitepress/config.ts" "/guide/apt-repository"
assert_contains "apps/docs/docs/.vitepress/config.ts" "/developer/apt-repository"
assert_contains "apps/docs/docs/.vitepress/config.ts" "/guide/fedora-repository"
assert_contains "apps/docs/docs/.vitepress/config.ts" "/developer/fedora-repository"
+assert_contains "apps/docs/docs/.vitepress/config.ts" "/guide/opensuse-repository"
+assert_contains "apps/docs/docs/.vitepress/config.ts" "/developer/opensuse-repository"
assert_contains "apps/docs/docs/guide/apt-repository.md" "Signed-By"
assert_contains "apps/docs/docs/guide/apt-repository.md" "--allow-downgrades"
assert_contains "apps/docs/docs/developer/apt-repository.md" "APT_REPOSITORY_ENABLED"
@@ -79,6 +83,12 @@ assert_contains "apps/docs/docs/guide/fedora-repository.md" "repo_gpgcheck=1"
assert_contains "apps/docs/docs/guide/fedora-repository.md" "sudo dnf downgrade loopwire"
assert_contains "apps/docs/docs/developer/fedora-repository.md" "FEDORA_REPOSITORY_ENABLED"
assert_contains "apps/docs/docs/developer/fedora-repository.md" "Final activation is a human operation"
+assert_contains "apps/docs/docs/guide/opensuse-repository.md" "repo_gpgcheck=1"
+assert_contains "apps/docs/docs/guide/opensuse-repository.md" "pkg_gpgcheck=1"
+assert_contains "apps/docs/docs/guide/opensuse-repository.md" "sudo zypper install --oldpackage"
+assert_contains "apps/docs/docs/developer/opensuse-repository.md" "OPENSUSE_REPOSITORY_ENABLED"
+assert_contains "apps/docs/docs/developer/opensuse-repository.md" "A failed newer-snapshot run blocks activation"
+assert_contains "apps/docs/docs/developer/opensuse-repository.md" "Final activation is a human operation"
assert_contains "apps/docs/docs/.vitepress/config.ts" 'base: "/docs/"'
assert_contains "apps/docs/docs/.vitepress/config.ts" "/developer/release-notes"
assert_contains "apps/docs/docs/.vitepress/config.ts" "/release-notes/0.1.0"
diff --git a/scripts/verify-github-workflows.sh b/scripts/verify-github-workflows.sh
index 51e2f6a..02dab6a 100755
--- a/scripts/verify-github-workflows.sh
+++ b/scripts/verify-github-workflows.sh
@@ -97,6 +97,7 @@ workflows=(
".github/workflows/ci.yml"
".github/workflows/publish-apt.yml"
".github/workflows/publish-fedora.yml"
+ ".github/workflows/publish-opensuse.yml"
".github/workflows/web.yml"
".github/workflows/aur.yml"
".github/workflows/workflow-checks.yml"
@@ -129,6 +130,10 @@ assert_contains ".github/workflows/aur.yml" "scripts/verify-aur-source-package.s
assert_contains ".github/workflows/aur.yml" "scripts/verify-aur-git-package.sh"
assert_contains "package.json" '"verify:tauri": "bash scripts/verify-tauri.sh"'
assert_contains "package.json" "pnpm verify:tauri"
+assert_contains ".github/workflows/publish-opensuse.yml" "OPENSUSE_REPOSITORY_ENABLED"
+assert_contains ".github/workflows/publish-opensuse.yml" "environment: packages-production"
+assert_contains ".github/workflows/publish-opensuse.yml" "bash scripts/publish-opensuse-workflow.sh"
+assert_contains ".github/workflows/release.yml" "publish-opensuse:"
assert_contains ".github/workflows/continuous-tests.yml" "schedule:"
assert_contains ".github/workflows/continuous-tests.yml" "scripts/ct-host-check.sh"
@@ -339,6 +344,7 @@ ruby "$root/scripts/test-ci-impact.rb"
ruby "$root/scripts/test-ci-workflow-paths.rb"
ruby "$root/scripts/test-apt-workflow.rb"
ruby "$root/scripts/test-fedora-workflow.rb"
+ruby "$root/scripts/test-opensuse-workflow.rb"
node "$root/scripts/test-native-package-proof-snapshot.mjs"
echo "GitHub workflow contract verification passed."
diff --git a/scripts/verify-opensuse-public.py b/scripts/verify-opensuse-public.py
new file mode 100755
index 0000000..77df662
--- /dev/null
+++ b/scripts/verify-opensuse-public.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+"""Verify a served openSUSE repository before producing its website activation record."""
+import argparse
+from datetime import datetime, timezone
+import hashlib
+import json
+from pathlib import Path
+import re
+import ssl
+import subprocess
+import sys
+import tempfile
+import urllib.error
+import urllib.parse
+import urllib.request
+
+
+TARGET = {"distribution": "opensuse", "release": "tumbleweed", "architecture": "x86_64"}
+TARGET_SELECTOR = "opensuse-tumbleweed-x86_64"
+
+
+class NoRedirects(urllib.request.HTTPRedirectHandler):
+ def redirect_request(self, request, response, code, message, headers, new_url):
+ response.close()
+ raise ValueError("repository verification does not follow redirects; use the canonical HTTPS URL")
+
+
+def validate_base_url(value):
+ url = urllib.parse.urlsplit(value)
+ if (url.scheme != "https" or not url.hostname or url.username or url.password
+ or any(char in value for char in "\\'\"`$<>?#")
+ or any(ord(char) <= 32 or ord(char) >= 127 for char in value)):
+ raise ValueError("base URL must be HTTPS without credentials, whitespace, query, fragment, or shell metacharacters")
+ if url.port is not None and not 1 <= url.port <= 65535:
+ raise ValueError("invalid HTTPS port in base URL")
+ return value.rstrip("/")
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--repository", required=True, type=Path)
+ parser.add_argument("--public-key", required=True, type=Path)
+ parser.add_argument("--fingerprint", required=True)
+ parser.add_argument("--base-url", required=True)
+ parser.add_argument("--ca-file", type=Path, help="custom CA for isolated test servers")
+ parser.add_argument("--proof-url", help="GitHub Actions run URL required for --output")
+ parser.add_argument("--output", type=Path, help="write verified channel configuration after all checks")
+ args = parser.parse_args()
+ base_url = validate_base_url(args.base_url)
+ if args.output and args.ca_file:
+ raise ValueError("custom-CA fixture checks cannot produce public activation records")
+ if args.output and (not args.proof_url or not re.fullmatch(
+ r"https://github\.com/sandwichfarm/loopwire/actions/runs/[1-9][0-9]*", args.proof_url)):
+ raise ValueError("activation output requires the verifying project GitHub Actions run URL")
+ fingerprint = args.fingerprint.upper()
+ if not re.fullmatch(r"[A-F0-9]{40}", fingerprint):
+ raise ValueError("a complete OpenPGP fingerprint is required")
+ subprocess.run([
+ sys.executable, str(Path(__file__).with_name("rpm-repository.py")), "verify",
+ "--target", TARGET_SELECTOR, "--repository", str(args.repository),
+ "--public-key", str(args.public_key), "--fingerprint", fingerprint,
+ ], check=True, stdout=subprocess.PIPE)
+ manifest_path = args.repository / "repository-manifest.json"
+ manifest = json.loads(manifest_path.read_text())
+ if manifest.get("target") != TARGET:
+ raise ValueError("candidate does not target openSUSE Tumbleweed x86_64")
+ manifest_bytes = manifest_path.read_bytes()
+ entries = [*manifest["files"], {
+ "path": "repository-manifest.json", "size": len(manifest_bytes),
+ "sha256": hashlib.sha256(manifest_bytes).hexdigest(),
+ }]
+ context = ssl.create_default_context(cafile=str(args.ca_file) if args.ca_file else None)
+ opener = urllib.request.build_opener(NoRedirects(), urllib.request.HTTPSHandler(context=context))
+ for entry in entries:
+ url = base_url + "/" + urllib.parse.quote(entry["path"], safe="/+")
+ request = urllib.request.Request(url, headers={
+ "Cache-Control": "no-cache", "User-Agent": "Loopwire-openSUSE-Proof/1"})
+ try:
+ response = opener.open(request, timeout=30)
+ except urllib.error.HTTPError as error:
+ status = error.code
+ error.close()
+ raise ValueError(f"repository returned HTTP {status} for {entry['path']}") from None
+ digest, size = hashlib.sha256(), 0
+ with response:
+ while chunk := response.read(1024 * 1024):
+ size += len(chunk)
+ if size > entry["size"]:
+ raise ValueError(f"public file is larger than expected: {entry['path']}")
+ digest.update(chunk)
+ if size != entry["size"] or digest.hexdigest() != entry["sha256"]:
+ raise ValueError(f"public file differs from the verified candidate: {entry['path']}")
+ record = {
+ "schemaVersion": 1, "status": "verified", "target": "opensuse-tumbleweed",
+ "baseUrl": base_url, "signingFingerprint": fingerprint, "revision": manifest["revision"],
+ "verifiedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "proofUrl": args.proof_url,
+ }
+ if args.output:
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ with tempfile.NamedTemporaryFile(mode="w", dir=args.output.parent, delete=False) as temporary:
+ json.dump(record, temporary, indent=2)
+ temporary.write("\n")
+ temporary_path = Path(temporary.name)
+ temporary_path.replace(args.output)
+ print(json.dumps({"status": "verified", "revision": manifest["revision"], "files": len(entries)}))
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except (OSError, ValueError, KeyError, subprocess.CalledProcessError, urllib.error.URLError) as error:
+ print(f"verify-opensuse-public: {error}", file=sys.stderr)
+ sys.exit(1)
diff --git a/scripts/verify-opensuse-repository-vm-proof.mjs b/scripts/verify-opensuse-repository-vm-proof.mjs
new file mode 100755
index 0000000..ea24707
--- /dev/null
+++ b/scripts/verify-opensuse-repository-vm-proof.mjs
@@ -0,0 +1,390 @@
+#!/usr/bin/env node
+import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
+import { spawnSync } from "node:child_process";
+import { lstat, readFile, readdir } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ parseInstalledHashes,
+ parsePayloadRelease,
+ parseReleaseChecksums,
+ rpmPayload,
+ verifyReleaseSignature,
+ verifyRpmSignature,
+} from "./verify-fedora-repository-vm-proof.mjs";
+
+const repositoryRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
+const repositoryTarget = "opensuse-tumbleweed-x86_64";
+const requiredPaths = [
+ "/usr/bin/loopwire", "/usr/bin/loopwire-dsp-provider", "/usr/bin/loopwire-jack-ports",
+ "/usr/bin/loopwire-detect-audio", "/usr/lib/loopwire/loopwire-gui",
+ "/usr/share/applications/loopwire.desktop", "/usr/share/icons/hicolor/scalable/apps/loopwire.svg",
+];
+
+function requireThat(condition, message) {
+ if (!condition) throw new Error(message);
+}
+async function bytes(directory, name) {
+ const file = path.join(directory, name);
+ const stat = await lstat(file);
+ requireThat(stat.isFile() && !stat.isSymbolicLink(), `evidence must be a regular file: ${name}`);
+ return readFile(file);
+}
+async function text(directory, name, nonempty = true) {
+ const result = (await bytes(directory, name)).toString("utf8");
+ requireThat(!nonempty || result.trim(), `empty evidence: ${name}`);
+ return result;
+}
+function equal(actual, expected, label) {
+ requireThat(actual === expected, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
+}
+function command(program, args) {
+ const result = spawnSync(program, args, { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
+ requireThat(!result.error && result.status === 0,
+ `${program} verification failed: ${result.error?.message ?? result.stderr ?? result.stdout}`);
+ return result.stdout;
+}
+function sha256(buffer) { return createHash("sha256").update(buffer).digest("hex"); }
+function tsvMap(value, label) {
+ const map = new Map();
+ for (const line of value.trimEnd().split("\n")) {
+ const separator = line.indexOf("\t");
+ requireThat(separator > 0, `${label} must contain key/value TSV`);
+ const key = line.slice(0, separator);
+ requireThat(!map.has(key), `${label} repeats ${key}`);
+ map.set(key, line.slice(separator + 1));
+ }
+ return map;
+}
+function stageTable(value, label) {
+ const result = new Map();
+ for (const line of value.trimEnd().split("\n")) {
+ const fields = line.split("\t");
+ requireThat(fields.length === 3 && ["baseline", "upgraded"].includes(fields[0]), `invalid ${label} row`);
+ requireThat(!result.has(fields[0]), `${label} repeats ${fields[0]}`);
+ requireThat(/^loopwire-[0-9A-Za-z.+~_-]+-1\.x86_64\.rpm$/.test(fields[1]), `invalid ${label} package name`);
+ requireThat(/^[a-f0-9]{64}$/.test(fields[2]), `invalid ${label} SHA-256`);
+ result.set(fields[0], { name: fields[1], sha256: fields[2] });
+ }
+ requireThat(result.size === 2, `${label} must contain baseline and upgraded rows`);
+ return result;
+}
+
+export function targetManifestRow(value, target) {
+ const rows = value.split("\n")
+ .filter((line) => line && !line.startsWith("#"))
+ .map((line) => line.split("\t"))
+ .filter((row) => row[0] === target);
+ requireThat(rows.length === 1, "target missing or duplicate in image manifest");
+ requireThat(rows[0].length === 9 && rows[0].every((field) => field.length > 0),
+ "target image manifest row must contain nine nonempty fields");
+ return rows[0];
+}
+
+export function verifyReleaseAssetManifest(value, expected) {
+ const manifest = JSON.parse(value);
+ assert.deepEqual(Object.keys(manifest).sort(), ["artifacts", "release", "schema"], "release manifest fields");
+ equal(manifest.schema, "loopwire.release-assets.v1", "release manifest schema");
+ assert.deepEqual(Object.keys(manifest.release).sort(), ["gitHead", "tag", "version"], "release identity fields");
+ equal(manifest.release.tag, `v${expected.version}`, "public release tag");
+ equal(manifest.release.version, expected.version, "public release version");
+ requireThat(/^[a-f0-9]{40}$/.test(manifest.release.gitHead), "public release commit must be a full lowercase hash");
+ requireThat(Array.isArray(manifest.artifacts), "release artifacts must be an array");
+ const opensuse = manifest.artifacts.filter((entry) => entry && entry.target === "opensuse-tumbleweed");
+ equal(opensuse.length, 1, "openSUSE release artifact count");
+ assert.deepEqual(Object.keys(opensuse[0]).sort(), ["architecture", "bytes", "kind", "name", "sha256", "target"],
+ "openSUSE release artifact fields");
+ assert.deepEqual(opensuse[0], { name: expected.rpmName, kind: "native-rpm", target: "opensuse-tumbleweed",
+ architecture: "x86_64", bytes: expected.rpmBytes, sha256: expected.rpmSha256 }, "openSUSE release artifact");
+ const portable = manifest.artifacts.filter((entry) => entry?.name === "loopwire-linux-x86_64.tar.gz");
+ equal(portable.length, 1, "x86_64 portable release artifact count");
+ for (const [key, wanted] of Object.entries({ kind: "portable-archive", target: "linux-generic", architecture: "x86_64",
+ bytes: expected.tarBytes, sha256: expected.tarSha256 })) equal(portable[0][key], wanted, `portable release artifact ${key}`);
+ return manifest;
+}
+
+export function verifyLifecycle(value, baselineVersion, upgradeVersion) {
+ equal(value.trimEnd(), [
+ `install\t${baselineVersion}\tinstalled`, `reinstall\t${baselineVersion}\tinstalled`,
+ `upgrade\t${upgradeVersion}\tinstalled`, `rollback\t${baselineVersion}\tinstalled`,
+ `remove\t${baselineVersion}\tabsent`,
+ ].join("\n"), "lifecycle transitions");
+}
+
+export function verifyPackageEntry(entry, expected, packageSha256, sourceSha256, label) {
+ requireThat(entry && typeof entry === "object" && !Array.isArray(entry), `${label} package entry missing`);
+ assert.deepEqual(Object.keys(entry).sort(), ["architecture", "distributedSha256", "name", "path", "release", "size",
+ "sourceReleaseSha256", "sourceRevision", "version"], `${label} package entry fields`);
+ equal(entry.name, "loopwire", `${label} package name`);
+ equal(entry.version, expected.version, `${label} package version`);
+ equal(entry.release, "1", `${label} package release`);
+ equal(entry.architecture, "x86_64", `${label} package architecture`);
+ equal(entry.path, `packages/${expected.name}`, `${label} package path`);
+ equal(entry.sourceReleaseSha256, sourceSha256, `${label} source release hash`);
+ equal(entry.sourceRevision, expected.sourceRevision, `${label} public source revision`);
+ equal(entry.distributedSha256, packageSha256, `${label} distributed RPM hash`);
+ requireThat(Number.isSafeInteger(entry.size) && entry.size > 0, `${label} package size missing`);
+}
+
+function xmlAttributes(value) {
+ const result = {};
+ for (const match of value.matchAll(/([A-Za-z][A-Za-z0-9_-]*)="([^"]*)"/g)) result[match[1]] = match[2];
+ return result;
+}
+
+export function verifyZypperSearch(value, version) {
+ const records = [...value.matchAll(/]*)\/>/g)].map((match) => xmlAttributes(match[1]))
+ .filter((entry) => entry.name === "loopwire" && entry.edition === version);
+ equal(records.length, 1, "Zypper repository candidate result count");
+ for (const [key, expected] of Object.entries({ name: "loopwire", edition: version, arch: "x86_64",
+ repository: "Loopwire for openSUSE Tumbleweed - x86_64" })) {
+ equal(records[0][key], expected, `Zypper ${key}`);
+ }
+}
+
+export function verifyZypperInstalledSearch(value, version) {
+ const records = [...value.matchAll(/]*)\/>/g)].map((match) => xmlAttributes(match[1]))
+ .filter((entry) => entry.name === "loopwire");
+ equal(records.length, 1, "Zypper installed package result count");
+ for (const [key, expected] of Object.entries({ status: "installed", name: "loopwire", edition: version,
+ arch: "x86_64" })) equal(records[0][key], expected, `installed Zypper ${key}`);
+ requireThat(["Loopwire for openSUSE Tumbleweed - x86_64", "(System Packages)"].includes(records[0].repository),
+ "installed Zypper repository is neither the active candidate nor the native system view");
+}
+
+export async function verifyInstalledStage(directory, stage, version, fingerprint, packageName, packageSha256, payloadHashes) {
+ const prefix = `${stage}/`;
+ equal((await text(directory, `${prefix}package-metadata.tsv`)).trim(),
+ `loopwire\t${version}\tx86_64\t(none)`, `${stage} package metadata/vendor`);
+ equal((await text(directory, `${prefix}zypper-origin.tsv`)).trim(),
+ `loopwire\t${version}\tx86_64\tLoopwire for openSUSE Tumbleweed - x86_64\t(none)`,
+ `${stage} repository origin/vendor`);
+ verifyZypperInstalledSearch(await text(directory, `${prefix}zypper-search.xml`), version);
+ verifyZypperSearch(await text(directory, `${prefix}zypper-repository-search.xml`), version);
+ const info = await text(directory, `${prefix}zypper-info.txt`);
+ requireThat(/^Name\s*:\s*loopwire$/m.test(info) &&
+ new RegExp(`^Version\\s*:\\s*${version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "m").test(info),
+ `${stage} Zypper package info lacks repository/version`);
+ const files = (await text(directory, `${prefix}package-files.txt`)).trim().split("\n");
+ for (const installed of requiredPaths) requireThat(files.includes(installed), `${stage} missing ${installed}`);
+ assert.deepEqual(parseInstalledHashes(await text(directory, `${prefix}installed-files.sha256`)), payloadHashes,
+ `${stage} installed bytes must equal the signed repository RPM payload`);
+ equal((await text(directory, `${prefix}signed-package.sha256`)).trim(), `${packageSha256} ${packageName}`,
+ `${stage} signed RPM digest`);
+ verifyRpmSignature(await text(directory, `${prefix}rpm-signature.txt`), fingerprint, packageName);
+ for (const help of ["background-help.txt", "dsp-provider-help.txt", "jack-provider-help.txt"]) await text(directory, `${prefix}${help}`);
+ const detection = JSON.parse(await text(directory, `${prefix}detect-audio.json`));
+ requireThat(detection && typeof detection === "object", `${stage} detection must be JSON object/array`);
+ const linkage = await text(directory, `${prefix}gui-ldd.txt`);
+ requireThat(!linkage.includes("not found") && /libgtk-3/.test(linkage) && /libwebkit2gtk/.test(linkage),
+ `${stage} GUI linkage missing or unresolved`);
+ equal((await text(directory, `${prefix}gui-launch-status.txt`)).trim(), "0", `${stage} GUI launch`);
+ requireThat(/^\d+(?:\n\d+)*\n?$/.test(await text(directory, `${prefix}gui-window-ids.txt`)), `${stage} lacks X11 window ids`);
+ const names = (await text(directory, `${prefix}gui-window-names.txt`)).trim().split("\n");
+ requireThat(names.every((name) => /^(Loopwire|loopwire-gui)$/.test(name)), `${stage} lacks Loopwire application window`);
+ requireThat(!/error while loading shared libraries|panic|protocol error|missing acquire timeline/i.test(
+ await text(directory, `${prefix}gui-launch.log`, false)), `${stage} fatal GUI log`);
+ await text(directory, `${prefix}xvfb.log`, false);
+}
+
+export async function verifyEvidence({ target, evidenceDir, gitHead, targetManifest }) {
+ equal(target, "opensuse-tumbleweed", "supported target");
+ requireThat(/^[a-f0-9]{40}$/.test(gitHead ?? ""), "--git-head must be a full lowercase commit hash");
+ requireThat(typeof targetManifest === "string" && targetManifest, "target manifest is required");
+ const summary = tsvMap(await text(evidenceDir, "summary.tsv"), "summary");
+ equal(summary.get("schema"), "loopwire.opensuse-repository-vm-proof.v1", "schema");
+ equal(summary.get("target"), target, "target");
+ const testedSnapshot = summary.get("snapshot");
+ requireThat(/^[0-9]{8}$/.test(testedSnapshot ?? ""), "tested Tumbleweed snapshot must be YYYYMMDD");
+ equal(summary.get("git_head"), gitHead, "summary commit");
+ equal((await text(evidenceDir, "git-head.txt")).trim(), gitHead, "evidence commit");
+ equal(summary.get("payload_kind"), "public-release-baseline-with-synthetic-upgrade", "payload provenance kind");
+ equal(summary.get("synthetic_upgrade"), "true", "synthetic fixture disclosure");
+ const version = summary.get("version");
+ requireThat(/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:\+[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?$/.test(version ?? ""), "invalid baseline version");
+ const upgradedVersion = `${version}${version.includes("+") ? "." : "+"}zypperfixture1`;
+ equal(summary.get("upgrade_version"), upgradedVersion, "synthetic fixture version");
+ const baselineVersion = `${version}-1`;
+ const upgradeVersion = `${upgradedVersion}-1`;
+ equal(summary.get("baseline_package_version"), baselineVersion, "baseline package version");
+ equal(summary.get("upgrade_package_version"), upgradeVersion, "upgrade package version");
+ const fingerprint = summary.get("fingerprint");
+ requireThat(/^[A-F0-9]{40}$/.test(fingerprint ?? ""), "invalid signing fingerprint");
+ const baseUrl = summary.get("base_url");
+ equal(baseUrl, "https://127.0.0.1:8445/opensuse/tumbleweed/x86_64", "guest-only HTTPS origin");
+ const epoch = summary.get("verification_epoch");
+ requireThat(/^[0-9]{10}$/.test(epoch ?? "") && Number(epoch) <= Date.now() / 1000 + 300, "invalid proof timestamp");
+ const os = new Map((await text(evidenceDir, "os-release")).split("\n").filter((line) => /^[A-Z_]+=/.test(line)).map((line) => {
+ const separator = line.indexOf("=");
+ return [line.slice(0, separator), line.slice(separator + 1).replace(/^"|"$/g, "")];
+ }));
+ equal(os.get("ID"), "opensuse-tumbleweed", "guest OS");
+ equal(os.get("VERSION_ID"), testedSnapshot, "guest snapshot");
+ requireThat(["kvm", "qemu"].includes((await text(evidenceDir, "virtualization.txt")).trim()), "not a VM proof");
+ requireThat((await text(evidenceDir, "uname.txt")).includes("Linux"), "guest kernel evidence missing");
+ await text(evidenceDir, "console.log");
+ const row = targetManifestRow(await readFile(targetManifest, "utf8"), target);
+ const image = tsvMap(await text(evidenceDir, "image.tsv"), "image");
+ for (const [key, expected] of Object.entries({ schema: "loopwire.native-package-image.v1", target,
+ distro: row[1], url: row[4], checksum_algorithm: row[5], checksum: row[6], actual_checksum: row[6], firmware: row[8] })) {
+ equal(image.get(key), expected, `image ${key}`);
+ }
+ equal((await text(evidenceDir, "initial-package-status.txt")).trim(), "absent", "clean guest status");
+ requireThat(/^[a-f0-9]{64} {2}loopwire-linux-x86_64\.tar\.gz\n?$/.test(await text(evidenceDir, "release-payload.sha256")),
+ "missing original payload digest");
+
+ const publicDirectory = path.join(evidenceDir, "public-release");
+ const publicRpmName = `loopwire-${baselineVersion}.x86_64.rpm`;
+ assert.deepEqual((await readdir(publicDirectory)).sort(), ["RELEASE", "SHA256SUMS", "SHA256SUMS.sig", publicRpmName,
+ "loopwire-linux-x86_64.tar.gz", "release-assets.json", "release-signing-public.pem"].sort(),
+ "public release evidence inventory");
+ equal(await text(publicDirectory, "release-signing-public.pem"),
+ await readFile(path.join(repositoryRoot, "packaging/release-signing-public.pem"), "utf8"), "committed release signing key");
+ verifyReleaseSignature(path.join(publicDirectory, "SHA256SUMS"), path.join(publicDirectory, "SHA256SUMS.sig"),
+ path.join(repositoryRoot, "packaging/release-signing-public.pem"));
+ const checksums = parseReleaseChecksums(await text(publicDirectory, "SHA256SUMS"));
+ const publicRpm = await bytes(publicDirectory, publicRpmName);
+ const publicTar = await bytes(publicDirectory, "loopwire-linux-x86_64.tar.gz");
+ const publicManifest = await bytes(publicDirectory, "release-assets.json");
+ for (const [name, content] of [[publicRpmName, publicRpm], ["loopwire-linux-x86_64.tar.gz", publicTar],
+ ["release-assets.json", publicManifest]]) {
+ requireThat(checksums.has(name), `signed checksums lack ${name}`);
+ equal(checksums.get(name), sha256(content), `signed public release hash for ${name}`);
+ }
+ const releaseManifest = verifyReleaseAssetManifest(publicManifest.toString("utf8"), {
+ version, rpmName: publicRpmName, rpmBytes: publicRpm.length, rpmSha256: sha256(publicRpm),
+ tarBytes: publicTar.length, tarSha256: sha256(publicTar),
+ });
+ parsePayloadRelease(await text(publicDirectory, "RELEASE"), version);
+ equal(await text(evidenceDir, "payload-release.txt"), await text(publicDirectory, "RELEASE"), "captured RELEASE data");
+ equal(summary.get("public_release_git_head"), releaseManifest.release.gitHead, "summary public release commit");
+ equal((await text(evidenceDir, "public-release-git-head.txt")).trim(), releaseManifest.release.gitHead,
+ "captured public release commit");
+
+ const source = await text(evidenceDir, "loopwire.repo");
+ for (const line of ["[loopwire]", "type=rpm-md", `baseurl=${baseUrl}`, "enabled=1", "autorefresh=1", "priority=99",
+ "gpgcheck=1", "repo_gpgcheck=1", "pkg_gpgcheck=1",
+ `gpgkey=file:///etc/zypp/keys/loopwire-repository-${fingerprint}.asc`]) {
+ requireThat(source.split("\n").includes(line), `Zypper source lacks ${line}`);
+ }
+ requireThat(!/gpgcheck\s*=\s*0|repo_gpgcheck\s*=\s*0|pkg_gpgcheck\s*=\s*0|ssl_?verify\s*=\s*(?:0|no|false)|keeppackages\s*=\s*1/i.test(source),
+ "Zypper proof bypasses authentication or retains packages unexpectedly");
+ equal(await text(evidenceDir, "https-key.asc"), await text(evidenceDir, "repository-key.asc"), "HTTPS public key");
+ equal(await text(evidenceDir, "configured-repository-key.asc"), await text(evidenceDir, "repository-key.asc"), "configured public key");
+ command("openssl", ["verify", "-attime", epoch, "-CAfile", path.join(evidenceDir, "tls-ca.crt"),
+ "-verify_ip", "127.0.0.1", path.join(evidenceDir, "tls-server.crt")]);
+
+ const sources = stageTable(await text(evidenceDir, "release-sources.tsv"), "release sources");
+ const signed = stageTable(await text(evidenceDir, "signed-packages.tsv"), "signed packages");
+ const expectedNames = { baseline: publicRpmName, upgraded: `loopwire-${upgradeVersion}.x86_64.rpm` };
+ equal(sources.get("baseline").sha256, sha256(publicRpm), "baseline source must be the public release RPM");
+ equal(summary.get("baseline_source_sha256"), sha256(publicRpm), "summary public baseline source hash");
+ for (const stage of ["baseline", "upgraded"]) {
+ equal(sources.get(stage).name, expectedNames[stage], `${stage} source RPM name`);
+ equal(signed.get(stage).name, expectedNames[stage], `${stage} signed RPM name`);
+ const summaryPrefix = stage === "upgraded" ? "upgrade" : stage;
+ equal(summary.get(`${summaryPrefix}_source_sha256`), sources.get(stage).sha256, `${stage} summary source hash`);
+ equal(summary.get(`${summaryPrefix}_rpm_sha256`), signed.get(stage).sha256, `${stage} summary signed hash`);
+ equal(sha256(await bytes(evidenceDir, `packages/${expectedNames[stage]}`)), signed.get(stage).sha256,
+ `${stage} signed RPM evidence hash`);
+ }
+ assert.deepEqual((await readdir(path.join(evidenceDir, "packages"))).sort(), ["baseline-rpm-signature.txt",
+ expectedNames.baseline, expectedNames.upgraded, "upgraded-rpm-signature.txt"].sort(), "package evidence inventory");
+ verifyRpmSignature(await text(evidenceDir, "packages/baseline-rpm-signature.txt"), fingerprint, expectedNames.baseline);
+ verifyRpmSignature(await text(evidenceDir, "packages/upgraded-rpm-signature.txt"), fingerprint, expectedNames.upgraded);
+ const payloadHashes = {
+ [baselineVersion]: await rpmPayload(path.join(evidenceDir, "packages", expectedNames.baseline)),
+ [upgradeVersion]: await rpmPayload(path.join(evidenceDir, "packages", expectedNames.upgraded)),
+ };
+ assert.deepEqual(await rpmPayload(path.join(publicDirectory, publicRpmName)), payloadHashes[baselineVersion],
+ "repository signing must preserve the public release RPM payload");
+
+ for (const stage of ["initial", "upgraded", "rolled-back"]) {
+ const directory = path.join(evidenceDir, "repositories", stage);
+ const result = command("bash", [path.join(repositoryRoot, "scripts/with-opensuse-rpm-tools.sh"), "--read-only-path", evidenceDir,
+ "python3", path.join(repositoryRoot, "scripts/rpm-repository.py"), "verify", "--target", repositoryTarget,
+ "--repository", directory, "--public-key", path.join(evidenceDir, "repository-key.asc"),
+ "--fingerprint", fingerprint, "--now", epoch]);
+ JSON.parse(result);
+ JSON.parse(await text(evidenceDir, `repositories/${stage}-verification.json`));
+ const manifest = JSON.parse(await text(directory, "repository-manifest.json"));
+ equal(manifest.schema, "loopwire.rpm-repository.v1", `${stage} repository schema`);
+ equal(manifest.schemaVersion, 1, `${stage} repository schema version`);
+ assert.deepEqual(manifest.target, { distribution: "opensuse", release: "tumbleweed", architecture: "x86_64" },
+ `${stage} repository target`);
+ const served = JSON.parse(await text(evidenceDir, `repositories/${stage}-public-verification.json`));
+ equal(served.status, "verified", `${stage} HTTPS verification`);
+ equal(served.revision, manifest.revision, `${stage} HTTPS revision`);
+ equal(served.files, manifest.files.length + 1, `${stage} HTTPS file count including manifest`);
+ const expectedVersions = stage === "upgraded" ? [baselineVersion, upgradeVersion] : [baselineVersion];
+ equal(manifest.packages.length, expectedVersions.length, `${stage} package count`);
+ for (const expectedVersion of expectedVersions) {
+ const fixtureStage = expectedVersion === baselineVersion ? "baseline" : "upgraded";
+ const matches = manifest.packages.filter((entry) => entry.name === "loopwire" && `${entry.version}-${entry.release}` === expectedVersion);
+ requireThat(matches.length === 1, `${stage} must contain exactly one ${expectedVersion} package`);
+ verifyPackageEntry(matches[0], { version: expectedVersion.replace(/-1$/, ""), name: expectedNames[fixtureStage],
+ sourceRevision: releaseManifest.release.gitHead },
+ signed.get(fixtureStage).sha256, sources.get(fixtureStage).sha256, `${stage} ${expectedVersion}`);
+ }
+ if (stage !== "upgraded") requireThat(!manifest.packages.some((entry) => entry.version === upgradedVersion),
+ `${stage} unexpectedly advertises upgrade`);
+ }
+
+ verifyLifecycle(await text(evidenceDir, "lifecycle.tsv"), baselineVersion, upgradeVersion);
+ for (const [stage, expectedVersion] of Object.entries({ install: baselineVersion, reinstall: baselineVersion,
+ upgrade: upgradeVersion, rollback: baselineVersion })) {
+ const fixtureStage = expectedVersion === baselineVersion ? "baseline" : "upgraded";
+ await verifyInstalledStage(evidenceDir, stage, expectedVersion, fingerprint, expectedNames[fixtureStage],
+ signed.get(fixtureStage).sha256, payloadHashes[expectedVersion]);
+ const log = await text(evidenceDir, `${stage}.log`);
+ requireThat(log.includes("loopwire") && log.includes(expectedVersion), `${stage} lacks Zypper operation/version log`);
+ requireThat(log.includes(
+ `Retrieving: loopwire-${expectedVersion}.x86_64 (Loopwire for openSUSE Tumbleweed - x86_64)`),
+ `${stage} operation log lacks authenticated repository origin`);
+ }
+ const commands = await text(evidenceDir, "commands.log");
+ for (const needle of ["zypper --non-interactive install --from loopwire", "--force --no-allow-vendor-change --no-allow-arch-change",
+ "zypper --non-interactive update --repo loopwire loopwire", "--oldpackage --force",
+ "zypper --non-interactive remove loopwire", " -Kv ", "smoke_installed", "xdotool"]) {
+ requireThat(commands.includes(needle), `missing executed command: ${needle}`);
+ }
+ for (const file of ["bootstrap.log", "bootstrap-refresh.log", "upgrade-refresh.log", "rollback-refresh.log",
+ "remove.log", "source-removal.log", "source-removal-clean.log"]) await text(evidenceDir, file);
+ const requests = await text(evidenceDir, "https-server.log");
+ for (const requested of [`/opensuse/tumbleweed/x86_64/keys/${fingerprint}.asc`,
+ "/opensuse/tumbleweed/x86_64/repodata/repomd.xml", "/opensuse/tumbleweed/x86_64/repodata/repomd.xml.asc",
+ `/opensuse/tumbleweed/x86_64/packages/${expectedNames.baseline}`,
+ `/opensuse/tumbleweed/x86_64/packages/${expectedNames.upgraded}`]) {
+ requireThat(requests.includes(requested), `missing real HTTPS request: ${requested}`);
+ }
+ const removal = tsvMap(await text(evidenceDir, "removed-files.tsv"), "removed paths");
+ for (const removed of [...requiredPaths.filter((value) => value !== "/usr/lib/loopwire/loopwire-gui"), "/usr/lib/loopwire"]) {
+ equal(removal.get(removed), "absent", `removed ${removed}`);
+ }
+ requireThat(!/(^|[\s|])loopwire([\s|]|$)/m.test(await text(evidenceDir, "source-removal-repositories.txt")),
+ "removed Zypper source remains active");
+ return { target, snapshot: testedSnapshot, gitHead, baselineVersion, upgradeVersion, fingerprint };
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ try {
+ const args = {};
+ for (let index = 2; index < process.argv.length; index += 2) {
+ const option = process.argv[index];
+ requireThat(["--target", "--evidence-dir", "--git-head", "--target-manifest"].includes(option) && process.argv[index + 1], `invalid option: ${option}`);
+ requireThat(!Object.hasOwn(args, option), `duplicate option: ${option}`);
+ args[option] = process.argv[index + 1];
+ }
+ requireThat(args["--evidence-dir"], "--evidence-dir is required");
+ const targetManifest = path.resolve(args["--target-manifest"] ??
+ path.join(repositoryRoot, "packaging/vm/native-package-targets.tsv"));
+ const result = await verifyEvidence({ target: args["--target"], evidenceDir: path.resolve(args["--evidence-dir"]),
+ gitHead: args["--git-head"], targetManifest });
+ console.log(`openSUSE repository VM proof verified: ${result.target} snapshot ${result.snapshot}`);
+ console.log(JSON.stringify(result));
+ } catch (error) {
+ console.error(`verify-opensuse-repository-vm-proof: ${error.message}`);
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/verify-opensuse-repository.sh b/scripts/verify-opensuse-repository.sh
new file mode 100755
index 0000000..97fe7f0
--- /dev/null
+++ b/scripts/verify-opensuse-repository.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+set -euo pipefail
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+if [ "${1:-}" != --inside ]; then
+ exec bash "$root/scripts/with-opensuse-rpm-tools.sh" --container \
+ bash "$root/scripts/verify-opensuse-repository.sh" --inside
+fi
+cd "$root"
+export PYTHONDONTWRITEBYTECODE=1
+bash -n scripts/setup-opensuse-repository.sh scripts/publish-opensuse-workflow.sh \
+ scripts/with-opensuse-rpm-tools.sh packaging/vm/guest-opensuse-repository-smoke.sh
+shellcheck scripts/setup-opensuse-repository.sh scripts/publish-opensuse-workflow.sh \
+ scripts/with-opensuse-rpm-tools.sh packaging/vm/guest-opensuse-repository-smoke.sh \
+ scripts/verify-opensuse-repository.sh
+node --check scripts/verify-opensuse-repository-vm-proof.mjs
+node --check scripts/test-opensuse-repository-vm-proof.mjs
+for script in scripts/rpm-repository.py scripts/publish-rpm-repository.py \
+ scripts/verify-opensuse-public.py scripts/test-opensuse-bootstrap.py scripts/test-opensuse-public.py \
+ scripts/test-opensuse-workflow-preflight.py; do
+ python3 - "$script" <<'PY'
+import ast
+import pathlib
+import sys
+path = pathlib.Path(sys.argv[1])
+ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+PY
+done
+python3 scripts/test-rpm-repository.py
+python3 scripts/test-publish-rpm-repository.py --with-ssh
+python3 scripts/test-opensuse-bootstrap.py
+python3 scripts/test-opensuse-public.py
+python3 scripts/test-opensuse-workflow-preflight.py
+ruby scripts/test-opensuse-workflow.rb
+node scripts/test-opensuse-repository-vm-proof.mjs
+node --test apps/site/src/lib/opensuseChannel.test.mjs
+echo 'openSUSE repository development verification passed.'
diff --git a/scripts/verify-requirements.sh b/scripts/verify-requirements.sh
index dbe75a9..176e953 100644
--- a/scripts/verify-requirements.sh
+++ b/scripts/verify-requirements.sh
@@ -124,9 +124,10 @@ done
assert_script "package.json" "check" "pnpm check:verify && pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm verify:site"
assert_script "package.json" "check:verify" \
- "pnpm verify:requirements && pnpm verify:docs && pnpm test:setup-github && pnpm verify:scripts && pnpm verify:workflows && pnpm verify:runtime && pnpm verify:tauri && pnpm verify:apt && pnpm verify:rpm-repository"
+ "pnpm verify:requirements && pnpm verify:docs && pnpm test:setup-github && pnpm verify:scripts && pnpm verify:workflows && pnpm verify:runtime && pnpm verify:tauri && pnpm verify:apt && pnpm verify:rpm-repository && pnpm verify:opensuse-repository"
assert_script "package.json" "verify:apt" "bash scripts/verify-apt-repository.sh"
assert_script "package.json" "verify:rpm-repository" "bash scripts/verify-rpm-repository.sh"
+assert_script "package.json" "verify:opensuse-repository" "bash scripts/verify-opensuse-repository.sh"
assert_script "package.json" "verify:requirements" "bash scripts/verify-requirements.sh"
assert_script "package.json" "setup:github" "node scripts/setup-github-actions.mjs"
assert_script "package.json" "test:setup-github" "node scripts/test-setup-github-actions.mjs"
diff --git a/scripts/verify-rpm-repository.sh b/scripts/verify-rpm-repository.sh
index 9dec45d..153c105 100755
--- a/scripts/verify-rpm-repository.sh
+++ b/scripts/verify-rpm-repository.sh
@@ -17,4 +17,5 @@ python3 scripts/test-rpm-public.py
python3 scripts/test-fedora-workflow-preflight.py
node scripts/test-fedora-repository-vm-proof.mjs
node --test apps/site/src/lib/rpmChannel.test.mjs
+node --test apps/site/src/lib/opensuseChannel.test.mjs
echo 'Fedora repository development verification passed.'
diff --git a/scripts/verify-scripts.sh b/scripts/verify-scripts.sh
index 270f81d..cd148eb 100755
--- a/scripts/verify-scripts.sh
+++ b/scripts/verify-scripts.sh
@@ -56,8 +56,13 @@ bash -n \
scripts/build-native-packages.sh \
scripts/build-portable-linux-binary.sh \
scripts/native-package-vm.sh \
+ scripts/setup-opensuse-repository.sh \
+ scripts/publish-opensuse-workflow.sh \
+ scripts/with-opensuse-rpm-tools.sh \
+ scripts/verify-opensuse-repository.sh \
scripts/promote-native-package-vm-proof.sh \
packaging/vm/guest-native-package-smoke.sh \
+ packaging/vm/guest-opensuse-repository-smoke.sh \
scripts/verify-requirements.sh \
scripts/verify-docs.sh
@@ -106,6 +111,9 @@ node --check scripts/test-apt-repository-vm-proof.mjs
node --check scripts/verify-fedora-repository-vm-proof.mjs
node --check scripts/test-fedora-repository-vm-proof.mjs
node scripts/test-fedora-repository-vm-proof.mjs
+node --check scripts/verify-opensuse-repository-vm-proof.mjs
+node --check scripts/test-opensuse-repository-vm-proof.mjs
+node scripts/test-opensuse-repository-vm-proof.mjs
bash -n packaging/vm/guest-fedora-repository-smoke.sh
bash scripts/build-portable-linux-binary.sh -- --help | grep -Fq -- "--output FILE" || {
echo "verify-scripts: portable builder does not accept the package-script separator" >&2
diff --git a/scripts/with-opensuse-rpm-tools.sh b/scripts/with-opensuse-rpm-tools.sh
new file mode 100755
index 0000000..a758c37
--- /dev/null
+++ b/scripts/with-opensuse-rpm-tools.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+image="${LOOPWIRE_OPENSUSE_RPM_TOOLS_IMAGE:-loopwire-rpm-tools:opensuse-tumbleweed}"
+force_container=false
+mounts=()
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --container) force_container=true; shift ;;
+ --read-only-path)
+ input="$(realpath -e "${2:?missing --read-only-path value}")"
+ mounts+=(--volume "$input:$input:ro")
+ shift 2
+ ;;
+ *) break ;;
+ esac
+done
+[ "$#" -gt 0 ] || {
+ echo 'Usage: with-opensuse-rpm-tools.sh [--container] [--read-only-path PATH] COMMAND [ARG ...]' >&2
+ exit 2
+}
+available=true
+for command in createrepo_c gpg gpgv openssl python3 rpm rpmkeys rpmsign zypper; do
+ command -v "$command" >/dev/null 2>&1 || available=false
+done
+export PYTHONDONTWRITEBYTECODE=1
+if [ "$available" = true ] && [ "$force_container" = false ]; then
+ exec "$@"
+fi
+command -v docker >/dev/null 2>&1 || {
+ echo 'openSUSE repository tools or Docker are required; see the openSUSE repository guide.' >&2
+ exit 1
+}
+if ! docker image inspect "$image" >/dev/null 2>&1; then
+ docker build --file "$root/packaging/repositories/Dockerfile.opensuse-rpm-tools" --tag "$image" "$root" >&2
+fi
+exec docker run --rm --network none --env PYTHONDONTWRITEBYTECODE=1 \
+ --volume "$root:$root:ro" "${mounts[@]}" --workdir "$root" "$image" "$@"