Build the shared F-Droid publishing tooling: sign, index, publish
Adds sign-apk.sh (align-check + apksigner), fdroid-update.sh (fdroid update over a binary repo, signing the index with its own separate key), publish.sh (S3-compatible upload) and the composite action tying them together, matching weblib-ci's shape. zipalign is not a standalone nixpkgs attribute; resolved by measurement rather than assumption that Gradle/AGP already aligns its output (Android's own docs, corroborated by fdroidserver never shelling out to zipalign) and added check_zip_alignment.py as the safety net, since apksigner itself does not check alignment at all. nixpkgs is pinned via a committed nixpkgs-pin revision, since this repo has no flake.lock of its own for with-nixpkgs.sh to read (fdroid-repo-ci#1, weblib-ci#9) -- a missing or malformed pin fails loudly rather than degrading to an unpinned nix shell. No consumer repo, bucket, hostname or channel name is hard-coded: which repo holds the keys and runs the publish has already changed once during this issue and may again, so every example uses a placeholder. 42 offline/local-only test assertions across five suites (run_tests.sh), including fault injection on the alignment check, the password/argument validation, and the with-nixpkgs.sh fail-loud paths. Co-authored-by: bit <bit@das-labor.org>
This commit is contained in:
@@ -0,0 +1,228 @@
|
|||||||
|
# fdroid-repo-ci
|
||||||
|
|
||||||
|
Shared tooling to sign a prebuilt APK, build a signed F-Droid repo index over
|
||||||
|
it, and publish the result to an S3-compatible bucket -- consumed as a
|
||||||
|
composite action by whichever repository currently holds the signing keys
|
||||||
|
and runs the publish. Which repository that is has changed more than once,
|
||||||
|
so nothing here names it; see "Not hard-coded" below.
|
||||||
|
|
||||||
|
Same shape as [weblib-ci], and public for the same measured reason: a
|
||||||
|
private repository cannot be consumed cross-repo via `uses:` on this Gitea
|
||||||
|
instance (see weblib-ci's README) -- every cross-repo form fails while the
|
||||||
|
repo being referenced is private, and only the full-URL form works once it
|
||||||
|
is public.
|
||||||
|
|
||||||
|
**Nothing secret belongs here, ever.** Keys, keystores, credentials, bucket
|
||||||
|
names and hostnames all live in the private repo that consumes this one and
|
||||||
|
arrive as inputs -- arguments and environment variables, never committed
|
||||||
|
here.
|
||||||
|
|
||||||
|
## What is here
|
||||||
|
|
||||||
|
| script | what it does |
|
||||||
|
|---|---|
|
||||||
|
| `sign-apk.sh` | check alignment, then sign a prebuilt unsigned release APK with a keystore the caller supplies |
|
||||||
|
| `fdroid-update.sh` | run `fdroid update` over a `repo/` directory of already-signed APKs and produce the signed index |
|
||||||
|
| `publish.sh` | upload the resulting repo tree to an S3-compatible bucket |
|
||||||
|
| `with-nixpkgs.sh` | run a command with one or more nixpkgs packages on PATH, pinned to the revision in `nixpkgs-pin` |
|
||||||
|
| `check_zip_alignment.py` | the alignment check `sign-apk.sh` runs before signing (see "Align before sign" below) |
|
||||||
|
| `action.yml` | the composite action a consumer includes to get all of the above |
|
||||||
|
|
||||||
|
Deliberately **not** in scope: fetching the APK. That needs an upstream
|
||||||
|
credential and is the consumer's business, not the tooling's -- which also
|
||||||
|
keeps this repo from knowing which upstreams exist.
|
||||||
|
|
||||||
|
All of it is standard library / plain bash plus the pinned tools below. They
|
||||||
|
are *run*, not built, so this repo has no flake -- see "Pinning nixpkgs"
|
||||||
|
below for what that costs and why it is still the right call here.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- id: ci
|
||||||
|
uses: https://git.chaosbit.de/weblib/fdroid-repo-ci@main
|
||||||
|
- env:
|
||||||
|
APK_KEYSTORE_PASSWORD: ${{ secrets.APK_KEYSTORE_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
bash "${{ steps.ci.outputs.path }}/sign-apk.sh" \
|
||||||
|
app-release-unsigned.apk app-release.apk \
|
||||||
|
"$APK_KEYSTORE" release-key APK_KEYSTORE_PASSWORD
|
||||||
|
|
||||||
|
- env:
|
||||||
|
INDEX_KEYSTORE_PASSWORD: ${{ secrets.INDEX_KEYSTORE_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
bash "${{ steps.ci.outputs.path }}/fdroid-update.sh" \
|
||||||
|
"$FDROID_REPO_ROOT" "$INDEX_KEYSTORE" repo-key INDEX_KEYSTORE_PASSWORD
|
||||||
|
|
||||||
|
- env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.PUBLISH_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.PUBLISH_SECRET_ACCESS_KEY }}
|
||||||
|
run: |
|
||||||
|
bash "${{ steps.ci.outputs.path }}/publish.sh" \
|
||||||
|
"$FDROID_REPO_ROOT/repo" "$BUCKET" "$S3_ENDPOINT_URL"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use the full URL, `https://git.chaosbit.de/weblib/fdroid-repo-ci@main`, not
|
||||||
|
`weblib/fdroid-repo-ci@main`.** Measured on weblib-ci (weblib-archive#44):
|
||||||
|
the bare `owner/repo` form resolves against the instance's default actions
|
||||||
|
URL rather than this host and fails outright. The full-URL form is also the
|
||||||
|
only one that works while a referenced repo is private -- irrelevant for
|
||||||
|
*this* repo, which is public on purpose, but relevant for whatever repo is
|
||||||
|
consuming it, since that one holds the keys.
|
||||||
|
|
||||||
|
`$APK_KEYSTORE`/`$INDEX_KEYSTORE`/`$FDROID_REPO_ROOT`/`$BUCKET`/
|
||||||
|
`$S3_ENDPOINT_URL` above are the consumer's own paths and values -- nothing
|
||||||
|
in this repo defaults or hints at them. Env var *names* like
|
||||||
|
`APK_KEYSTORE_PASSWORD` are the consumer's choice too, not a convention this
|
||||||
|
repo imposes: `sign-apk.sh` and `fdroid-update.sh` each take the name of the
|
||||||
|
environment variable to read a password from as an argument, specifically so
|
||||||
|
two different keystores in the same job (the APK key and the separate repo
|
||||||
|
index key -- see below) never have to share one variable name.
|
||||||
|
|
||||||
|
## Align before sign, and how "no `zipalign`" was verified
|
||||||
|
|
||||||
|
`zipalign` then `apksigner`. Signing first and aligning after is the old
|
||||||
|
`jarsigner`-era order and invalidates an `apksigner`-era (v2/v3) signature,
|
||||||
|
because that scheme signs the exact bytes of the packaged zip and alignment
|
||||||
|
padding changes those bytes.
|
||||||
|
|
||||||
|
**`apksigner` is a standalone nixpkgs attribute** (`nixpkgs#apksigner`,
|
||||||
|
verified at 35.0.6). **`zipalign` is not** -- it exists only inside
|
||||||
|
`androidenv`'s composed build-tools, which pulls a full versioned Android SDK
|
||||||
|
and its licence machinery for one binary. Before accepting that as a
|
||||||
|
dependency, three things were measured rather than assumed:
|
||||||
|
|
||||||
|
1. **Google's own docs** (developer.android.com/tools/zipalign): "If you
|
||||||
|
build using Android Studio, which uses the Android Gradle plugin (AGP),
|
||||||
|
this is done automatically. In this case, you should still use zipalign
|
||||||
|
to verify that the APK is aligned, but you don't need to align it." AGP is
|
||||||
|
what `flutter build apk` (or any Gradle-based Android build) drives
|
||||||
|
underneath, so this applies to any such build's output, not just
|
||||||
|
Android-Studio-initiated ones.
|
||||||
|
2. **`fdroidserver` 2.4.3's own source never calls `zipalign`, anywhere** --
|
||||||
|
grepped across all 38 of its modules. Its APK-signature-copying code
|
||||||
|
(`apksigcopier.py`, used for reproducible-build verification, a different
|
||||||
|
flow than binary-repo mode) implements realignment itself in pure Python
|
||||||
|
against the zip format directly, rather than shelling out to the
|
||||||
|
`zipalign` binary.
|
||||||
|
3. **`apksigner` does not check or enforce alignment at all**, in either
|
||||||
|
direction -- measured directly: a deliberately misaligned fixture zip
|
||||||
|
(including a `.so` misaligned to the stricter 4096-byte boundary) was
|
||||||
|
signed and then verified with `apksigner verify --verbose`, and the
|
||||||
|
output carried no warning about alignment whatsoever.
|
||||||
|
|
||||||
|
**Confidence: high** that a Gradle/AGP-built unsigned release APK is already
|
||||||
|
aligned, resting on (1) and (2) above. **Not independently confirmed**
|
||||||
|
against a real `flutter build apk` output run in this environment -- that
|
||||||
|
build was judged too expensive to attempt for this measurement. A sibling
|
||||||
|
repo's own CI proposal for building the Flutter app was rejected on the same
|
||||||
|
"the toolchain is too large to pull on every push" grounds this pin sidesteps
|
||||||
|
(composing a multi-platform Android SDK plus NDK pulls tens of gigabytes),
|
||||||
|
and the host available for this measurement had well under that in free
|
||||||
|
disk space.
|
||||||
|
|
||||||
|
Point (3) is why this repo does not simply trust point (1) and move on:
|
||||||
|
since nothing downstream complains about misalignment either, a build that
|
||||||
|
stopped emitting aligned output would fail silently -- an APK that signs and
|
||||||
|
verifies fine but behaves badly (or refuses to install) on a real device.
|
||||||
|
`check_zip_alignment.py`, a few lines against the standard library `zipfile`
|
||||||
|
module, checks every uncompressed entry of the APK `sign-apk.sh` is asked to
|
||||||
|
sign, and refuses to sign one that fails it. If that check ever legitimately
|
||||||
|
trips, the fix is to add `androidenv`'s `zipalign` as a dependency here, not
|
||||||
|
to weaken or remove the check.
|
||||||
|
|
||||||
|
## The repo index key is not the APK signing key
|
||||||
|
|
||||||
|
Binary-repo mode (every APK arrives already signed) means `fdroid update`
|
||||||
|
reads each APK's existing signature and never touches an app's own signing
|
||||||
|
key. It signs the **index** with a separate key of its own -- `fdroid-update.sh`
|
||||||
|
takes its own keystore/alias/password arguments, deliberately unrelated to
|
||||||
|
`sign-apk.sh`'s. Conflating the two would mean one compromised key
|
||||||
|
undermines both the apps and the index that vouches for them.
|
||||||
|
|
||||||
|
`fdroid-update.sh` writes `<repo-root>/config.yml` itself, on every run,
|
||||||
|
using fdroidserver's own `{env: VARNAME}` indirection for both passwords --
|
||||||
|
the exact mechanism its `common.py` implements *specifically so config.yml
|
||||||
|
can be safely git-managed*: "it is a good idea to manage config.yml (WITHOUT
|
||||||
|
PASSWORDS!) in git." A caller never has to know or hand-write fdroidserver's
|
||||||
|
config format.
|
||||||
|
|
||||||
|
## Pinning nixpkgs
|
||||||
|
|
||||||
|
`fdroidserver`'s output feeds a *signed index*, so an unpinned or
|
||||||
|
channel-drifting nixpkgs is a worse trade here than for a disposable test
|
||||||
|
job. This repo carries a `nixpkgs-pin` file -- one line, a 40-character
|
||||||
|
nixpkgs revision -- and `with-nixpkgs.sh` refuses to run at all if that file
|
||||||
|
is missing or malformed, rather than silently falling back to an unpinned
|
||||||
|
`nix shell nixpkgs#foo`, which re-resolves the registry and fetches whatever
|
||||||
|
the channel currently points at.
|
||||||
|
|
||||||
|
This is deliberately **not** the same mechanism as [weblib-ci]'s
|
||||||
|
`with-nixpkgs.sh`, which reads the *consuming* repo's `flake.lock`. This repo
|
||||||
|
has no flake (see weblib-ci#9 for the same tension in that repo: a
|
||||||
|
`flake.lock` existing only to hold one revision string), and per this repo's
|
||||||
|
own originating issue, the repo that consumes it holds signing keys and is
|
||||||
|
not guaranteed to be a flake either -- reading a caller's `flake.lock` the
|
||||||
|
way weblib-ci's script does would not even find one here. So the pin travels
|
||||||
|
with *this* repo instead, in a file named `nixpkgs-pin` on purpose: it is the
|
||||||
|
exact fallback weblib-ci#9 proposes adding to the shared script (flake.lock
|
||||||
|
present -> today's behaviour there; else `nixpkgs-pin` -> a bare revision;
|
||||||
|
else fail loudly). If that lands, this repo already speaks its format and
|
||||||
|
nothing here needs to change.
|
||||||
|
|
||||||
|
A `nixpkgs-pin` file has no `nix flake update` -- it is bumped by hand.
|
||||||
|
Accepted here for the same reason weblib-ci#9 accepted it for a
|
||||||
|
stdlib-only test suite, generalised: the alternative (a flake that builds
|
||||||
|
nothing, just to hold a lockfile) is a lot of structure for one string, and a
|
||||||
|
genuinely broken pinned nixpkgs would be caught immediately by this repo's
|
||||||
|
own test suite.
|
||||||
|
|
||||||
|
`fdroidserver` is in nixpkgs as `fdroidserver`, verified at 2.4.3.
|
||||||
|
`publish.sh` uses `awscli2` for the same S3-compatible upload every consumer
|
||||||
|
here needs, resolved through the same pin.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash run_tests.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
42 assertions across five suites, ~20s on a warm nix store:
|
||||||
|
|
||||||
|
* `test_check_zip_alignment.py` -- pure standard library, no nix, no
|
||||||
|
network at all; builds an aligned and a misaligned fixture zip by hand and
|
||||||
|
checks both directions, including the 4096-byte boundary specific to `.so`
|
||||||
|
entries.
|
||||||
|
* `test_with_nixpkgs.sh` -- offline; every way the pinning wrapper is
|
||||||
|
supposed to refuse rather than silently degrade (missing pin, malformed
|
||||||
|
pin, too few arguments). Does not exercise the happy path of actually
|
||||||
|
resolving a `github:nixos/nixpkgs/...` reference automatically, since that
|
||||||
|
needs network (or an already-warm local cache) the same way weblib-ci's
|
||||||
|
own `with-nixpkgs.sh` does, and that repo does not automate a test for it
|
||||||
|
either. It was checked by hand instead.
|
||||||
|
* `test_sign_apk.sh` and `test_fdroid_update.sh` -- full end-to-end runs
|
||||||
|
against a throwaway keystore generated fresh per run, real `apksigner`
|
||||||
|
and `fdroid update`. Needs the pinned toolchain to be resolvable (locally
|
||||||
|
cached, or fetched once over the network); nothing beyond that.
|
||||||
|
* `test_publish.sh` -- argument/credential validation and `--dry-run`
|
||||||
|
command construction, fully offline, plus one real invocation against a
|
||||||
|
closed local port (`127.0.0.1:1`, nothing listens there) to prove the
|
||||||
|
built command genuinely attempts a network call rather than failing on its
|
||||||
|
own arguments. The actual upload path is out of scope for an offline
|
||||||
|
suite, matching the constraint that only `publish.sh` needs network in the
|
||||||
|
first place.
|
||||||
|
|
||||||
|
No `.gitea/workflows` runs this, on the same reasoning weblib-ci#9 recorded:
|
||||||
|
run it by hand before pushing. If that changes for weblib-ci it should
|
||||||
|
probably change here too, at the same time.
|
||||||
|
|
||||||
|
## Not hard-coded
|
||||||
|
|
||||||
|
No `weblib/` (or any other organisation) in a path, URL or workflow
|
||||||
|
reference. No bucket name, repo URL, app id, keystore path, or channel name
|
||||||
|
or count -- a consumer may have one publish target or several, under
|
||||||
|
whatever names it likes; nothing here assumes "release" and "dev", or that
|
||||||
|
there are exactly two.
|
||||||
|
|
||||||
|
[weblib-ci]: https://git.chaosbit.de/weblib/weblib-ci
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
# Makes sign-apk.sh, fdroid-update.sh, publish.sh and with-nixpkgs.sh
|
||||||
|
# available to a workflow and reports where they are.
|
||||||
|
#
|
||||||
|
# Consume it with the **full URL**, the same reason recorded in weblib-ci's
|
||||||
|
# README: `uses: owner/repo@main` resolves against the instance's default
|
||||||
|
# actions URL rather than this host, and only the full URL form works while
|
||||||
|
# the repository being referenced is private -- and the repository holding
|
||||||
|
# the signing keys that consumes this one is private on purpose.
|
||||||
|
#
|
||||||
|
# - uses: actions/checkout@v4
|
||||||
|
# - id: ci
|
||||||
|
# uses: https://git.chaosbit.de/weblib/fdroid-repo-ci@main
|
||||||
|
# - run: bash "${{ steps.ci.outputs.path }}/sign-apk.sh" ...
|
||||||
|
#
|
||||||
|
# Nothing here reads a secret or names a bucket, a keystore or a consumer
|
||||||
|
# repo -- those all stay on the caller's side of this action's output path.
|
||||||
|
name: F-Droid repo publishing tools
|
||||||
|
description: >-
|
||||||
|
Shared, key-free tooling to align-check and sign a prebuilt APK, run
|
||||||
|
`fdroid update` over a binary repo, and publish the result to an
|
||||||
|
S3-compatible bucket. The caller supplies the keys, the bucket and the
|
||||||
|
APK; this action supplies the pinned toolchain and the scripts.
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
path:
|
||||||
|
description: >-
|
||||||
|
Directory holding with-nixpkgs.sh, nixpkgs-pin, sign-apk.sh,
|
||||||
|
fdroid-update.sh and publish.sh.
|
||||||
|
value: ${{ github.action_path }}
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- shell: bash
|
||||||
|
run: |
|
||||||
|
echo "fdroid-repo-ci tools at ${{ github.action_path }}"
|
||||||
|
for f in with-nixpkgs.sh nixpkgs-pin sign-apk.sh fdroid-update.sh publish.sh; do
|
||||||
|
test -f "${{ github.action_path }}/$f"
|
||||||
|
done
|
||||||
Executable
+96
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Check that every uncompressed entry in a zip/APK starts at an aligned offset.
|
||||||
|
|
||||||
|
Exists to *verify* rather than assume the zipalign decision recorded in
|
||||||
|
sign-apk.sh: this repo has no `zipalign` (it is not a standalone nixpkgs
|
||||||
|
attribute -- fdroid-repo-ci#1), on the strength of Android's own docs saying
|
||||||
|
a build made through the Android Gradle Plugin already aligns its zip entries
|
||||||
|
and that `zipalign` is only needed afterwards to check that, not to do it.
|
||||||
|
`apksigner` itself does not check alignment at all -- signing and verifying a
|
||||||
|
deliberately misaligned fixture produces no warning, measured while writing
|
||||||
|
this script -- so nothing downstream catches a regression here. This does.
|
||||||
|
|
||||||
|
Standard library only (`zipfile`, `struct`), like the rest of this repo's
|
||||||
|
Python. No `zipalign` binary is invoked or required.
|
||||||
|
|
||||||
|
Alignment only matters for *uncompressed* (STORED) entries: a compressed
|
||||||
|
entry is inflated into a fresh buffer before use, so its position in the zip
|
||||||
|
file is irrelevant. Two rules, matching what `zipalign`/`apksigner` apply:
|
||||||
|
|
||||||
|
* `.so` (native libraries) must start on a 4096-byte boundary, so the
|
||||||
|
loader can mmap them directly.
|
||||||
|
* everything else stored must start on a 4-byte boundary.
|
||||||
|
|
||||||
|
`zipfile.ZipInfo` records the *central directory's* header offset and extra
|
||||||
|
field, which is not always identical to the *local* file header at that
|
||||||
|
offset -- the two can carry different extra-field lengths. Reading the local
|
||||||
|
header directly, the same way apksigcopier's realignment code does, is what
|
||||||
|
makes this check exact rather than approximate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
LOCAL_HEADER_SIGNATURE = b"PK\x03\x04"
|
||||||
|
|
||||||
|
|
||||||
|
def local_data_offset(fh, header_offset):
|
||||||
|
"""Where this entry's data actually starts, per its local file header."""
|
||||||
|
fh.seek(header_offset)
|
||||||
|
sig = fh.read(4)
|
||||||
|
if sig != LOCAL_HEADER_SIGNATURE:
|
||||||
|
raise ValueError(f"no local file header at offset {header_offset} "
|
||||||
|
f"(got {sig!r})")
|
||||||
|
fh.seek(header_offset + 26)
|
||||||
|
name_len, extra_len = struct.unpack("<HH", fh.read(4))
|
||||||
|
return header_offset + 30 + name_len + extra_len
|
||||||
|
|
||||||
|
|
||||||
|
def alignment_for(filename):
|
||||||
|
return 4096 if filename.endswith(".so") else 4
|
||||||
|
|
||||||
|
|
||||||
|
def find_misaligned(path):
|
||||||
|
"""Uncompressed entries that do not start on their required boundary.
|
||||||
|
|
||||||
|
Returns a list of (filename, offset, required_alignment).
|
||||||
|
"""
|
||||||
|
problems = []
|
||||||
|
with zipfile.ZipFile(path) as zf, open(path, "rb") as fh:
|
||||||
|
for info in zf.infolist():
|
||||||
|
if info.compress_type != zipfile.ZIP_STORED:
|
||||||
|
continue
|
||||||
|
align = alignment_for(info.filename)
|
||||||
|
offset = local_data_offset(fh, info.header_offset)
|
||||||
|
if offset % align != 0:
|
||||||
|
problems.append((info.filename, offset, align))
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("apk", help="path to the zip/APK to check")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
problems = find_misaligned(args.apk)
|
||||||
|
if problems:
|
||||||
|
plural = "y" if len(problems) == 1 else "ies"
|
||||||
|
print(f"{args.apk}: {len(problems)} unaligned uncompressed entr{plural}:",
|
||||||
|
file=sys.stderr)
|
||||||
|
for name, offset, align in problems:
|
||||||
|
print(f" {name}: data starts at {offset}, not a multiple of {align}",
|
||||||
|
file=sys.stderr)
|
||||||
|
print("expected the build to already align these (see sign-apk.sh); "
|
||||||
|
"not signing an APK that would fail to install correctly",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"{args.apk}: all uncompressed entries aligned")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Run `fdroid update` over a `repo/` directory of already-signed APKs and
|
||||||
|
# produce the signed index.
|
||||||
|
#
|
||||||
|
# Binary-repo mode (fdroid-repo-ci#1): every APK under repo/ already carries
|
||||||
|
# its own signature -- from sign-apk.sh, or from whatever produced it -- so
|
||||||
|
# this step never touches an app's signing key. It only uses the separate
|
||||||
|
# *repo index* key, a different key entirely, which is what this script
|
||||||
|
# signs the index with.
|
||||||
|
#
|
||||||
|
# bash fdroid-update.sh <repo-root> <index-keystore> <key-alias> \
|
||||||
|
# <keystore-pass-env> [<key-pass-env>]
|
||||||
|
#
|
||||||
|
# <repo-root> is the directory *containing* repo/ -- fdroidserver's own
|
||||||
|
# layout, not repo/ itself. This script writes <repo-root>/config.yml itself
|
||||||
|
# on every run (see below), so an existing one is overwritten; nothing else
|
||||||
|
# under <repo-root> is touched beyond what `fdroid update` writes into repo/.
|
||||||
|
#
|
||||||
|
# <keystore-pass-env>/<key-pass-env> name environment variables holding the
|
||||||
|
# passwords, the same convention as sign-apk.sh and for the same reason: an
|
||||||
|
# argument is visible in the job log and in `ps`, a password must not be. If
|
||||||
|
# <key-pass-env> is omitted, the keystore password is reused as the key
|
||||||
|
# password.
|
||||||
|
#
|
||||||
|
# Needs no network (fdroid-repo-ci#1) -- it only reads repo/ and signs.
|
||||||
|
# Pins fdroidserver, the JDK (fdroidserver shells out to jarsigner/keytool
|
||||||
|
# for the index signature) and apksigner (for the per-entry v1 signature in
|
||||||
|
# newer index formats) via with-nixpkgs.sh/nixpkgs-pin, next to this script.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
|
||||||
|
if [ "$#" -lt 4 ]; then
|
||||||
|
cat >&2 <<'USAGE'
|
||||||
|
usage: fdroid-update.sh <repo-root> <index-keystore> <key-alias> \
|
||||||
|
<keystore-pass-env> [<key-pass-env>] [extra fdroid update args...]
|
||||||
|
USAGE
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
root=$1
|
||||||
|
keystore=$2
|
||||||
|
alias_name=$3
|
||||||
|
ks_pass_env=$4
|
||||||
|
shift 4
|
||||||
|
|
||||||
|
key_pass_env=$ks_pass_env
|
||||||
|
if [ "$#" -gt 0 ] && [[ $1 != -* ]]; then
|
||||||
|
key_pass_env=$1
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
|
||||||
|
extra_args=("$@")
|
||||||
|
|
||||||
|
[ -d "$root/repo" ] || { echo "fdroid-update.sh: no repo/ under $root" >&2; exit 2; }
|
||||||
|
[ -f "$keystore" ] || { echo "fdroid-update.sh: no such keystore: $keystore" >&2; exit 2; }
|
||||||
|
: "${!ks_pass_env:?fdroid-update.sh: environment variable $ks_pass_env (keystore password) is not set}"
|
||||||
|
: "${!key_pass_env:?fdroid-update.sh: environment variable $key_pass_env (key password) is not set}"
|
||||||
|
|
||||||
|
# fdroidserver's config.yml supports "{env: VARNAME}" in place of any string
|
||||||
|
# value specifically so a config that names a keystore and its passwords can
|
||||||
|
# still be free of secrets -- fdroidserver's own common.py: "it is a good
|
||||||
|
# idea to manage config.yml (WITHOUT PASSWORDS!) in git". Writing it here
|
||||||
|
# means a caller never has to hand-write fdroidserver's config format, and
|
||||||
|
# this repo -- not every consumer -- is the one place that has to track it if
|
||||||
|
# it changes.
|
||||||
|
cat > "$root/config.yml" <<EOF
|
||||||
|
keystore: $keystore
|
||||||
|
keystorepass:
|
||||||
|
env: $ks_pass_env
|
||||||
|
keypass:
|
||||||
|
env: $key_pass_env
|
||||||
|
repo_keyalias: $alias_name
|
||||||
|
EOF
|
||||||
|
chmod 600 "$root/config.yml"
|
||||||
|
|
||||||
|
echo "fdroid-update.sh: running fdroid update in $root"
|
||||||
|
# fdroidserver locates a JDK by globbing standard system install locations
|
||||||
|
# (or $JAVA_HOME) -- a nix store path matches neither, so JAVA_HOME has to be
|
||||||
|
# set explicitly from whichever jarsigner this nix shell actually put on
|
||||||
|
# PATH, rather than assumed.
|
||||||
|
bash "$here/with-nixpkgs.sh" "fdroidserver jdk21 apksigner" bash -c '
|
||||||
|
set -euo pipefail
|
||||||
|
export JAVA_HOME="$(dirname "$(dirname "$(command -v jarsigner)")")"
|
||||||
|
cd "$1"; shift
|
||||||
|
exec fdroid update "$@"
|
||||||
|
' bash "$root" "${extra_args[@]}"
|
||||||
|
|
||||||
|
echo "fdroid-update.sh: done; index at $root/repo/index-v2.json"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
6b316287bae2ee04c9b93c8c858d930fd07d7338
|
||||||
Executable
+95
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Upload a repo/ tree -- signed APKs plus the signed index -- to an
|
||||||
|
# S3-compatible bucket.
|
||||||
|
#
|
||||||
|
# AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... \
|
||||||
|
# bash publish.sh <repo-root>/repo <bucket> <endpoint-url> [<prefix>]
|
||||||
|
#
|
||||||
|
# Bucket, endpoint and prefix are all arguments, never baked in here: this
|
||||||
|
# repo does not know, and per fdroid-repo-ci#1 must never come to know, which
|
||||||
|
# bucket or host any consumer publishes to. Credentials come from the
|
||||||
|
# standard AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN
|
||||||
|
# environment variables that `aws` itself reads -- nothing here parses or
|
||||||
|
# forwards them as arguments.
|
||||||
|
#
|
||||||
|
# Region is required by the `aws` CLI even against a non-AWS S3-compatible
|
||||||
|
# backend that ignores it; set AWS_DEFAULT_REGION if the endpoint cares,
|
||||||
|
# otherwise the placeholder below is never actually consulted.
|
||||||
|
#
|
||||||
|
# Uses `aws s3 sync`, not `cp --recursive`: re-publishing re-uploads only
|
||||||
|
# what changed. Does **not** pass `--delete` by default -- an F-Droid repo
|
||||||
|
# normally keeps every APK it has ever served (older index entries can still
|
||||||
|
# point at them), so silently pruning the remote on every publish is the
|
||||||
|
# wrong default. Pass `--delete` as an extra argument if a caller genuinely
|
||||||
|
# wants the remote pruned to match local.
|
||||||
|
#
|
||||||
|
# `fdroid update` needs no network; this is the one script in this repo that
|
||||||
|
# does (fdroid-repo-ci#1), and is accordingly the one script whose happy path
|
||||||
|
# the test suite cannot exercise against a real bucket. `--dry-run` prints
|
||||||
|
# the exact command this script would run -- including the `with-nixpkgs.sh`
|
||||||
|
# pin -- and exits without touching the network, which is what the test
|
||||||
|
# suite asserts against. (A PATH-stubbed `aws` cannot do this job: `nix
|
||||||
|
# shell` puts the packages it was asked for at the *front* of PATH, ahead of
|
||||||
|
# anything already there, so a stub earlier on the caller's PATH would simply
|
||||||
|
# be shadowed by the real `aws` from awscli2.) Separately, the test suite
|
||||||
|
# also runs this for real against a closed local port, to confirm the
|
||||||
|
# constructed command is well-formed and genuinely attempts a network call
|
||||||
|
# rather than failing on argument parsing.
|
||||||
|
#
|
||||||
|
# Pins awscli2 via with-nixpkgs.sh/nixpkgs-pin, next to this script.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
|
||||||
|
if [ "$#" -lt 3 ]; then
|
||||||
|
cat >&2 <<'USAGE'
|
||||||
|
usage: publish.sh [--dry-run] <local-repo-dir> <bucket> <endpoint-url> [<prefix>] [extra aws s3 sync args...]
|
||||||
|
|
||||||
|
Credentials: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (and optionally
|
||||||
|
AWS_SESSION_TOKEN) must already be exported -- never passed as arguments.
|
||||||
|
USAGE
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
dry_run=false
|
||||||
|
if [ "$1" = "--dry-run" ]; then
|
||||||
|
dry_run=true
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
|
||||||
|
local_dir=$1
|
||||||
|
bucket=$2
|
||||||
|
endpoint=$3
|
||||||
|
shift 3
|
||||||
|
|
||||||
|
prefix=""
|
||||||
|
if [ "$#" -gt 0 ] && [[ $1 != -* ]]; then
|
||||||
|
prefix=$1
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
|
||||||
|
extra_args=("$@")
|
||||||
|
|
||||||
|
[ -d "$local_dir" ] || { echo "publish.sh: no such directory: $local_dir" >&2; exit 2; }
|
||||||
|
: "${AWS_ACCESS_KEY_ID:?publish.sh: AWS_ACCESS_KEY_ID is not set}"
|
||||||
|
: "${AWS_SECRET_ACCESS_KEY:?publish.sh: AWS_SECRET_ACCESS_KEY is not set}"
|
||||||
|
|
||||||
|
dest="s3://$bucket"
|
||||||
|
if [ -n "$prefix" ]; then
|
||||||
|
dest="$dest/${prefix#/}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cmd=(bash "$here/with-nixpkgs.sh" awscli2 aws s3 sync "$local_dir" "$dest"
|
||||||
|
--endpoint-url "$endpoint"
|
||||||
|
--region "${AWS_DEFAULT_REGION:-us-east-1}"
|
||||||
|
"${extra_args[@]}")
|
||||||
|
|
||||||
|
if [ "$dry_run" = true ]; then
|
||||||
|
printf '%q ' "${cmd[@]}"
|
||||||
|
printf '\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "publish.sh: syncing $local_dir -> $dest (endpoint $endpoint)"
|
||||||
|
exec "${cmd[@]}"
|
||||||
Executable
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Runs every test in this repo and prints one passed/skipped/failed total.
|
||||||
|
# Not wired to any `.gitea/workflows` -- see the README for why, the same
|
||||||
|
# reasoning weblib-ci#9 recorded for that repo: run it by hand before
|
||||||
|
# pushing.
|
||||||
|
#
|
||||||
|
# bash run_tests.sh
|
||||||
|
#
|
||||||
|
# check_zip_alignment.py's own suite needs only python3 (via with-nixpkgs.sh,
|
||||||
|
# since none is assumed to be on PATH); everything else additionally needs
|
||||||
|
# apksigner/fdroidserver/jdk21/awscli2, resolved through the same pin. All of
|
||||||
|
# it stays on loopback/local-store -- nothing here reaches a real host,
|
||||||
|
# bucket or key.
|
||||||
|
set -uo pipefail
|
||||||
|
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
|
||||||
|
total_pass=0
|
||||||
|
total_skip=0
|
||||||
|
total_fail=0
|
||||||
|
any_hard_fail=0
|
||||||
|
|
||||||
|
run_python_suite() {
|
||||||
|
echo "=== test_check_zip_alignment.py ==="
|
||||||
|
local out
|
||||||
|
if out=$(bash "$here/with-nixpkgs.sh" python3 python3 "$here/test_check_zip_alignment.py" -v 2>&1); then
|
||||||
|
local n
|
||||||
|
n=$(echo "$out" | grep -oE '^Ran [0-9]+ test' | grep -oE '[0-9]+')
|
||||||
|
total_pass=$((total_pass + ${n:-0}))
|
||||||
|
echo "$out" | tail -3
|
||||||
|
else
|
||||||
|
total_fail=$((total_fail + 1))
|
||||||
|
any_hard_fail=1
|
||||||
|
echo "$out"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
run_shell_suite() {
|
||||||
|
local script=$1
|
||||||
|
echo "=== $script ==="
|
||||||
|
local out rc=0
|
||||||
|
out=$(bash "$here/$script" 2>&1) || rc=$?
|
||||||
|
echo "$out"
|
||||||
|
local p s f
|
||||||
|
p=$(echo "$out" | grep -oE '^passed=[0-9]+' | grep -oE '[0-9]+' | tail -1)
|
||||||
|
s=$(echo "$out" | grep -oE 'skipped=[0-9]+' | grep -oE '[0-9]+' | tail -1)
|
||||||
|
f=$(echo "$out" | grep -oE 'failed=[0-9]+' | grep -oE '[0-9]+' | tail -1)
|
||||||
|
if [ -z "$p" ] && [ -z "$f" ]; then
|
||||||
|
# the script itself crashed before reaching report_and_exit
|
||||||
|
total_fail=$((total_fail + 1))
|
||||||
|
any_hard_fail=1
|
||||||
|
else
|
||||||
|
total_pass=$((total_pass + ${p:-0}))
|
||||||
|
total_skip=$((total_skip + ${s:-0}))
|
||||||
|
total_fail=$((total_fail + ${f:-0}))
|
||||||
|
[ "${f:-0}" -ne 0 ] && any_hard_fail=1
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
run_python_suite
|
||||||
|
run_shell_suite test_with_nixpkgs.sh
|
||||||
|
run_shell_suite test_sign_apk.sh
|
||||||
|
run_shell_suite test_fdroid_update.sh
|
||||||
|
run_shell_suite test_publish.sh
|
||||||
|
|
||||||
|
echo "===================================="
|
||||||
|
echo "TOTAL: passed=$total_pass skipped=$total_skip failed=$total_fail"
|
||||||
|
exit "$any_hard_fail"
|
||||||
Executable
+117
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Align-check and sign a prebuilt, unsigned release APK with a keystore the
|
||||||
|
# caller supplies. Exists because binary-repo F-Droid mode never builds
|
||||||
|
# anything itself (fdroid-repo-ci#1) -- something still has to turn a Gradle
|
||||||
|
# build's output into a signed APK before `fdroid update` can read its
|
||||||
|
# certificate.
|
||||||
|
#
|
||||||
|
# KEYSTORE_PASSWORD=... bash sign-apk.sh \
|
||||||
|
# unsigned.apk signed.apk keystore.jks release-key KEYSTORE_PASSWORD
|
||||||
|
#
|
||||||
|
# Positional args: <unsigned.apk> <signed.apk> <keystore> <key-alias>
|
||||||
|
# <keystore-pass-env> [<key-pass-env>]. The last two are *names* of
|
||||||
|
# environment variables to read the passwords from, not the passwords
|
||||||
|
# themselves -- an argument shows up in the job log and in `ps`, a keystore
|
||||||
|
# password must not. If <key-pass-env> is omitted, the keystore password is
|
||||||
|
# reused as the key password, which is the common case for a keystore holding
|
||||||
|
# one key. Naming the variable rather than fixing it lets a caller with
|
||||||
|
# several keystores (an APK key here, a separate repo index key for
|
||||||
|
# fdroid-update.sh) avoid a collision without this repo inventing a
|
||||||
|
# convention for it.
|
||||||
|
#
|
||||||
|
# Any arguments after the fifth/sixth are forwarded verbatim to both
|
||||||
|
# `apksigner sign` and `apksigner verify`, e.g. `--min-sdk-version`/
|
||||||
|
# `--max-sdk-version` to bypass real manifest parsing for a fixture that has
|
||||||
|
# no valid binary AndroidManifest.xml -- see the test suite.
|
||||||
|
#
|
||||||
|
# ## Alignment, measured rather than assumed (fdroid-repo-ci#1)
|
||||||
|
#
|
||||||
|
# `zipalign` is not a standalone nixpkgs attribute -- only `androidenv`'s
|
||||||
|
# composed build-tools carry it, and that pulls a full versioned Android SDK
|
||||||
|
# for one binary. Before adding that dependency:
|
||||||
|
#
|
||||||
|
# developer.android.com/tools/zipalign states plainly that a build made
|
||||||
|
# through the Android Gradle Plugin -- which `flutter build apk` drives
|
||||||
|
# underneath, same as a plain Android Studio build -- already aligns its zip
|
||||||
|
# entries via zipflinger, and that `zipalign` is only needed afterwards to
|
||||||
|
# *verify* that, not to perform it. `apksigner` does not check or complain
|
||||||
|
# about alignment either way: signing and verifying a deliberately misaligned
|
||||||
|
# fixture here produced no warning from `apksigner verify --verbose` at all --
|
||||||
|
# measured while writing this script, not inferred from the docs.
|
||||||
|
#
|
||||||
|
# So this script does not depend on `zipalign`, but it does not take the AGP
|
||||||
|
# claim on faith either: `check_zip_alignment.py`, a few lines against the
|
||||||
|
# stdlib `zipfile` module, checks every *uncompressed* entry -- the only ones
|
||||||
|
# alignment affects -- before signing, and refuses to sign an APK that fails
|
||||||
|
# it, rather than silently producing one that behaves badly on a device. If
|
||||||
|
# that check ever trips on a real build, the fix is to pull in androidenv's
|
||||||
|
# zipalign, not to weaken the check.
|
||||||
|
#
|
||||||
|
# Confidence in "AGP already aligns its output" is high (Google's own docs,
|
||||||
|
# corroborated by fdroidserver 2.4.3's source never shelling out to zipalign
|
||||||
|
# anywhere at all) but NOT independently confirmed here against a real
|
||||||
|
# `flutter build apk` output -- that build was judged too expensive to run
|
||||||
|
# for this measurement (weblib-viewer#5 already declined a CI job on the same
|
||||||
|
# toolchain for the same reason, and this host had ~7.5G free against a
|
||||||
|
# multi-platform Android SDK + NDK). check_zip_alignment.py is the safety net
|
||||||
|
# for that gap: it runs against every APK this script is asked to sign, not
|
||||||
|
# just the fixtures in the test suite.
|
||||||
|
#
|
||||||
|
# Pins apksigner via with-nixpkgs.sh/nixpkgs-pin, next to this script.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
|
||||||
|
if [ "$#" -lt 5 ]; then
|
||||||
|
cat >&2 <<'USAGE'
|
||||||
|
usage: sign-apk.sh <unsigned.apk> <signed.apk> <keystore> <key-alias> \
|
||||||
|
<keystore-pass-env> [<key-pass-env>] [extra apksigner args...]
|
||||||
|
|
||||||
|
<keystore-pass-env> and <key-pass-env> name environment variables holding the
|
||||||
|
passwords -- they are never passed as arguments.
|
||||||
|
USAGE
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
unsigned=$1
|
||||||
|
signed=$2
|
||||||
|
keystore=$3
|
||||||
|
alias_name=$4
|
||||||
|
ks_pass_env=$5
|
||||||
|
shift 5
|
||||||
|
|
||||||
|
key_pass_env=$ks_pass_env
|
||||||
|
if [ "$#" -gt 0 ] && [[ $1 != -* ]]; then
|
||||||
|
key_pass_env=$1
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
|
||||||
|
extra_args=("$@")
|
||||||
|
|
||||||
|
[ -f "$unsigned" ] || { echo "sign-apk.sh: no such file: $unsigned" >&2; exit 2; }
|
||||||
|
[ -f "$keystore" ] || { echo "sign-apk.sh: no such keystore: $keystore" >&2; exit 2; }
|
||||||
|
: "${!ks_pass_env:?sign-apk.sh: environment variable $ks_pass_env (keystore password) is not set}"
|
||||||
|
: "${!key_pass_env:?sign-apk.sh: environment variable $key_pass_env (key password) is not set}"
|
||||||
|
|
||||||
|
echo "sign-apk.sh: checking zip alignment of $unsigned"
|
||||||
|
bash "$here/with-nixpkgs.sh" python3 python3 "$here/check_zip_alignment.py" "$unsigned"
|
||||||
|
|
||||||
|
echo "sign-apk.sh: signing $unsigned -> $signed"
|
||||||
|
# apksigner's --ks-pass/--key-pass take "env:VARNAME", reading the variable's
|
||||||
|
# *current* value at apksigner's own invocation, not ours -- pass the names
|
||||||
|
# through so nothing beyond this script ever holds the password in an
|
||||||
|
# argument list.
|
||||||
|
bash "$here/with-nixpkgs.sh" apksigner apksigner sign \
|
||||||
|
--ks "$keystore" \
|
||||||
|
--ks-key-alias "$alias_name" \
|
||||||
|
--ks-pass "env:$ks_pass_env" \
|
||||||
|
--key-pass "env:$key_pass_env" \
|
||||||
|
--out "$signed" \
|
||||||
|
"${extra_args[@]}" \
|
||||||
|
"$unsigned"
|
||||||
|
|
||||||
|
echo "sign-apk.sh: verifying $signed"
|
||||||
|
bash "$here/with-nixpkgs.sh" apksigner apksigner verify --print-certs \
|
||||||
|
"${extra_args[@]}" \
|
||||||
|
"$signed"
|
||||||
Executable
+118
@@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Offline tests for check_zip_alignment.py.
|
||||||
|
|
||||||
|
Standard library only, no network, no nix, no external binary -- this is the
|
||||||
|
one piece of this repo that needs none of the pinned toolchain, so it is
|
||||||
|
tested at plain `python3` speed rather than through with-nixpkgs.sh.
|
||||||
|
|
||||||
|
Builds both a genuinely aligned fixture and a deliberately misaligned one
|
||||||
|
with nothing but `zipfile`/`struct`, and runs the checker as a *subprocess*
|
||||||
|
(not an import) against both, because that is how sign-apk.sh actually calls
|
||||||
|
it -- a test that only imports the function would miss an argv or exit-code
|
||||||
|
regression.
|
||||||
|
|
||||||
|
python3 test_check_zip_alignment.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
CHECKER = os.path.join(HERE, "check_zip_alignment.py")
|
||||||
|
|
||||||
|
|
||||||
|
def add_stored(zf, name, data, align=None):
|
||||||
|
"""Write a STORED entry, padded to `align` bytes if given.
|
||||||
|
|
||||||
|
Mirrors what a real zip-aligning tool does: pad the "extra field" of the
|
||||||
|
local header so the entry's *data* (not its header) starts on the
|
||||||
|
boundary. `align=None` writes the entry with no padding at all, which is
|
||||||
|
what a plain zipfile.writestr call already does by default -- the
|
||||||
|
negative-control case that must fail the check.
|
||||||
|
"""
|
||||||
|
info = zipfile.ZipInfo(name)
|
||||||
|
info.compress_type = zipfile.ZIP_STORED
|
||||||
|
if align:
|
||||||
|
header_len = 30 + len(name.encode())
|
||||||
|
data_start_if_no_extra = zf.fp.tell() + header_len
|
||||||
|
pad = (-data_start_if_no_extra) % align
|
||||||
|
if pad:
|
||||||
|
if pad < 4:
|
||||||
|
pad += align
|
||||||
|
extra_len = pad - 4
|
||||||
|
info.extra = struct.pack("<HH", 0xD935, extra_len) + b"\x00" * extra_len
|
||||||
|
zf.writestr(info, data)
|
||||||
|
|
||||||
|
|
||||||
|
class CheckZipAlignmentTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
|
||||||
|
def _run(self, path):
|
||||||
|
return subprocess.run([sys.executable, CHECKER, path],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
|
||||||
|
def _build(self, name, entries):
|
||||||
|
path = os.path.join(self.tmp.name, name)
|
||||||
|
with zipfile.ZipFile(path, "w") as zf:
|
||||||
|
for entry_name, data, align in entries:
|
||||||
|
add_stored(zf, entry_name, data, align)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def test_aligned_apk_passes(self):
|
||||||
|
path = self._build("aligned.apk", [
|
||||||
|
("AndroidManifest.xml", b"0123456789", 4),
|
||||||
|
("lib/arm64-v8a/libfoo.so", b"X" * 50, 4096),
|
||||||
|
])
|
||||||
|
result = self._run(path)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("all uncompressed entries aligned", result.stdout)
|
||||||
|
|
||||||
|
def test_misaligned_generic_entry_fails(self):
|
||||||
|
# No padding at all -- the fault this check exists to catch.
|
||||||
|
path = self._build("misaligned.apk", [
|
||||||
|
("x", b"pad", None),
|
||||||
|
("AndroidManifest.xml", b"0123456789", None),
|
||||||
|
])
|
||||||
|
result = self._run(path)
|
||||||
|
self.assertEqual(result.returncode, 1)
|
||||||
|
self.assertIn("AndroidManifest.xml", result.stderr)
|
||||||
|
self.assertIn("not a multiple of 4", result.stderr)
|
||||||
|
|
||||||
|
def test_misaligned_native_library_fails_at_4096(self):
|
||||||
|
# Aligned to 4 bytes (would pass a naive "mod 4" check) but not to
|
||||||
|
# the 4096 a .so specifically needs.
|
||||||
|
path = self._build("misaligned-so.apk", [
|
||||||
|
("lib/arm64-v8a/libfoo.so", b"X" * 50, 4),
|
||||||
|
])
|
||||||
|
result = self._run(path)
|
||||||
|
self.assertEqual(result.returncode, 1)
|
||||||
|
self.assertIn("libfoo.so", result.stderr)
|
||||||
|
self.assertIn("not a multiple of 4096", result.stderr)
|
||||||
|
|
||||||
|
def test_compressed_entries_are_never_flagged(self):
|
||||||
|
# Alignment only matters for STORED entries; a DEFLATEd one is
|
||||||
|
# decompressed into a fresh buffer regardless of its file offset.
|
||||||
|
path = os.path.join(self.tmp.name, "compressed.apk")
|
||||||
|
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr("classes.dex", b"\x00" * 100)
|
||||||
|
result = self._run(path)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
|
||||||
|
def test_fixing_alignment_flips_fail_to_pass(self):
|
||||||
|
"""The fault-injection/revert pair the task asked for, in one test."""
|
||||||
|
broken = self._build("broken.apk", [("AndroidManifest.xml", b"x" * 20, None)])
|
||||||
|
self.assertEqual(self._run(broken).returncode, 1)
|
||||||
|
|
||||||
|
fixed = self._build("fixed.apk", [("AndroidManifest.xml", b"x" * 20, 4)])
|
||||||
|
self.assertEqual(self._run(fixed).returncode, 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Executable
+91
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# End-to-end test of fdroid-update.sh: a throwaway index-signing keystore, a
|
||||||
|
# repo/ directory holding one already-signed fixture APK (from sign-apk.sh --
|
||||||
|
# binary-repo mode never builds or app-signs anything itself), a real
|
||||||
|
# `fdroid update` resolved through with-nixpkgs.sh/nixpkgs-pin.
|
||||||
|
#
|
||||||
|
# The fixture APK's AndroidManifest.xml is not real binary AXML -- building
|
||||||
|
# one needs aapt2, i.e. the Android SDK this repo deliberately avoids
|
||||||
|
# depending on. fdroidserver's own parser (androguard) rejects it and skips
|
||||||
|
# the app ("Failed to get APK information"), which is expected and asserted
|
||||||
|
# on below rather than hidden: what this test proves is the *indexing and
|
||||||
|
# index-signing* pipeline, which is what fdroid-update.sh exists for and
|
||||||
|
# what fdroid-repo-ci#1 asks to be verified -- "assert the index exists" --
|
||||||
|
# not that fdroidserver's manifest parser accepts a hand-built fixture.
|
||||||
|
set -euo pipefail
|
||||||
|
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
source "$here/test_lib.sh"
|
||||||
|
|
||||||
|
work=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
cd "$work"
|
||||||
|
mkdir -p repo
|
||||||
|
|
||||||
|
echo "generating throwaway index keystore..."
|
||||||
|
bash "$here/with-nixpkgs.sh" jdk21 keytool -genkeypair \
|
||||||
|
-keystore index.jks -storepass idxpass123 -keypass idxpass123 \
|
||||||
|
-alias indexkey -keyalg RSA -keysize 2048 -validity 1 \
|
||||||
|
-dname "CN=Index, OU=Test, O=Test, L=Test, S=Test, C=US" >/dev/null 2>&1
|
||||||
|
assert_file_exists "throwaway index keystore was created" index.jks
|
||||||
|
|
||||||
|
echo "signing a fixture APK with sign-apk.sh, to sit in repo/..."
|
||||||
|
bash "$here/with-nixpkgs.sh" jdk21 keytool -genkeypair \
|
||||||
|
-keystore app.jks -storepass apppass123 -keypass apppass123 \
|
||||||
|
-alias appkey -keyalg RSA -keysize 2048 -validity 1 \
|
||||||
|
-dname "CN=App, OU=Test, O=Test, L=Test, S=Test, C=US" >/dev/null 2>&1
|
||||||
|
bash "$here/with-nixpkgs.sh" python3 python3 - "$work" <<'PYEOF'
|
||||||
|
import struct, sys, zipfile
|
||||||
|
|
||||||
|
def add_stored(zf, name, data, align=4):
|
||||||
|
info = zipfile.ZipInfo(name)
|
||||||
|
info.compress_type = zipfile.ZIP_STORED
|
||||||
|
header_len = 30 + len(name.encode())
|
||||||
|
start = zf.fp.tell() + header_len
|
||||||
|
pad = (-start) % align
|
||||||
|
if pad:
|
||||||
|
if pad < 4:
|
||||||
|
pad += align
|
||||||
|
extra_len = pad - 4
|
||||||
|
info.extra = struct.pack("<HH", 0xD935, extra_len) + b"\x00" * extra_len
|
||||||
|
zf.writestr(info, data)
|
||||||
|
|
||||||
|
work = sys.argv[1]
|
||||||
|
with zipfile.ZipFile(f"{work}/unsigned.apk", "w") as zf:
|
||||||
|
add_stored(zf, "AndroidManifest.xml", b"0123456789")
|
||||||
|
PYEOF
|
||||||
|
export APP_KS_PASS=apppass123
|
||||||
|
bash "$here/sign-apk.sh" unsigned.apk repo/com.example.testapp_1.apk app.jks appkey APP_KS_PASS \
|
||||||
|
--min-sdk-version 21 --max-sdk-version 34 >/dev/null
|
||||||
|
assert_file_exists "fixture APK signed and placed in repo/" repo/com.example.testapp_1.apk
|
||||||
|
|
||||||
|
echo "--- fault: no repo/ directory is refused ---"
|
||||||
|
assert_failure "fdroid-update.sh refuses a root with no repo/" \
|
||||||
|
bash "$here/fdroid-update.sh" "$work/does-not-have-repo" index.jks indexkey IDX_KS_PASS
|
||||||
|
|
||||||
|
echo "--- fault: missing password env var is refused ---"
|
||||||
|
assert_failure "fdroid-update.sh refuses when the password env var is unset" \
|
||||||
|
env -u IDX_KS_PASS bash "$here/fdroid-update.sh" "$work" index.jks indexkey IDX_KS_PASS
|
||||||
|
|
||||||
|
echo "--- revert: a real run, needs no network ---"
|
||||||
|
export IDX_KS_PASS=idxpass123
|
||||||
|
assert_success "fdroid-update.sh runs fdroid update successfully" \
|
||||||
|
bash "$here/fdroid-update.sh" "$work" index.jks indexkey IDX_KS_PASS
|
||||||
|
|
||||||
|
assert_file_exists "index-v2.json exists and is non-empty" repo/index-v2.json
|
||||||
|
assert_file_exists "index-v1.jar (signed index) exists and is non-empty" repo/index-v1.jar
|
||||||
|
assert_file_exists "entry.jar (signed entry, apksigner v1) exists and is non-empty" repo/entry.jar
|
||||||
|
|
||||||
|
echo "--- index-v2.json is valid JSON, not just a non-empty file ---"
|
||||||
|
assert_success "index-v2.json parses as JSON" \
|
||||||
|
bash "$here/with-nixpkgs.sh" python3 python3 -c "import json; json.load(open('repo/index-v2.json'))"
|
||||||
|
|
||||||
|
echo "--- index-v1.jar is a validly signed jar (its own signature, not apksigner's) ---"
|
||||||
|
assert_success "jarsigner -verify accepts the signed index" \
|
||||||
|
bash "$here/with-nixpkgs.sh" jdk21 jarsigner -verify repo/index-v1.jar
|
||||||
|
|
||||||
|
echo "--- config.yml never holds a literal password ---"
|
||||||
|
assert_failure "config.yml has no literal password (uses {env: ...} instead)" \
|
||||||
|
grep -q "idxpass123" "$work/config.yml"
|
||||||
|
|
||||||
|
report_and_exit
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
# Shared assertion helpers for the shell test scripts. Sourced, not run.
|
||||||
|
#
|
||||||
|
# Each test_*.sh increments PASS_COUNT/FAIL_COUNT/SKIP_COUNT as it goes and
|
||||||
|
# prints one line per check, so a caller sees passed/skipped/failed as
|
||||||
|
# separate numbers rather than a single pass/fail verdict -- a skip must
|
||||||
|
# never read as a pass.
|
||||||
|
|
||||||
|
PASS_COUNT=0
|
||||||
|
FAIL_COUNT=0
|
||||||
|
SKIP_COUNT=0
|
||||||
|
|
||||||
|
pass() { PASS_COUNT=$((PASS_COUNT + 1)); echo "PASS: $1"; }
|
||||||
|
fail() { FAIL_COUNT=$((FAIL_COUNT + 1)); echo "FAIL: $1" >&2; }
|
||||||
|
skip() { SKIP_COUNT=$((SKIP_COUNT + 1)); echo "SKIP: $1"; }
|
||||||
|
|
||||||
|
assert_success() {
|
||||||
|
# assert_success <label> <command...>
|
||||||
|
local label=$1; shift
|
||||||
|
if "$@" >/tmp/assert_out.$$ 2>&1; then
|
||||||
|
pass "$label"
|
||||||
|
else
|
||||||
|
fail "$label (exit $?, see /tmp/assert_out.$$)"
|
||||||
|
cat /tmp/assert_out.$$ >&2
|
||||||
|
fi
|
||||||
|
rm -f /tmp/assert_out.$$
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_failure() {
|
||||||
|
# assert_failure <label> <command...> -- must exit non-zero
|
||||||
|
local label=$1; shift
|
||||||
|
if "$@" >/tmp/assert_out.$$ 2>&1; then
|
||||||
|
fail "$label (expected non-zero exit, got 0)"
|
||||||
|
cat /tmp/assert_out.$$ >&2
|
||||||
|
else
|
||||||
|
pass "$label"
|
||||||
|
fi
|
||||||
|
rm -f /tmp/assert_out.$$
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_file_exists() {
|
||||||
|
local label=$1 path=$2
|
||||||
|
if [ -s "$path" ]; then
|
||||||
|
pass "$label"
|
||||||
|
else
|
||||||
|
fail "$label (missing or empty: $path)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
# assert_contains <label> <haystack-file-or-string> <needle>
|
||||||
|
local label=$1 haystack=$2 needle=$3
|
||||||
|
if [ -f "$haystack" ]; then
|
||||||
|
haystack=$(cat "$haystack")
|
||||||
|
fi
|
||||||
|
if [[ "$haystack" == *"$needle"* ]]; then
|
||||||
|
pass "$label"
|
||||||
|
else
|
||||||
|
fail "$label (expected to find '$needle')"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
report_and_exit() {
|
||||||
|
echo
|
||||||
|
echo "passed=$PASS_COUNT skipped=$SKIP_COUNT failed=$FAIL_COUNT"
|
||||||
|
[ "$FAIL_COUNT" -eq 0 ]
|
||||||
|
}
|
||||||
Executable
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Tests for publish.sh -- the one script in this repo that needs real
|
||||||
|
# network to do its actual job (fdroid-repo-ci#1), so its happy path against
|
||||||
|
# a genuine bucket is out of scope here. What this asserts instead:
|
||||||
|
#
|
||||||
|
# * argument/credential validation, offline;
|
||||||
|
# * `--dry-run` builds the exact command a real run would use, offline;
|
||||||
|
# * a real invocation against a closed local port fails on the network call
|
||||||
|
# itself, not on argument parsing -- proving the command it built is
|
||||||
|
# well-formed, without needing a real bucket or reaching beyond loopback.
|
||||||
|
set -euo pipefail
|
||||||
|
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
source "$here/test_lib.sh"
|
||||||
|
|
||||||
|
work=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
mkdir -p "$work/repo"
|
||||||
|
echo '{}' > "$work/repo/index-v2.json"
|
||||||
|
|
||||||
|
echo "--- missing credentials are refused, not sent as empty ---"
|
||||||
|
assert_failure "publish.sh refuses with no AWS_ACCESS_KEY_ID" \
|
||||||
|
env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY \
|
||||||
|
bash "$here/publish.sh" "$work/repo" mybucket https://s3.example.invalid
|
||||||
|
|
||||||
|
export AWS_ACCESS_KEY_ID=AKIAFAKETESTKEY
|
||||||
|
export AWS_SECRET_ACCESS_KEY=fake-secret-not-real
|
||||||
|
|
||||||
|
echo "--- missing local directory is refused ---"
|
||||||
|
assert_failure "publish.sh refuses a non-existent local directory" \
|
||||||
|
bash "$here/publish.sh" "$work/does-not-exist" mybucket https://s3.example.invalid
|
||||||
|
|
||||||
|
echo "--- --dry-run builds the right command, touches no network ---"
|
||||||
|
out=$(bash "$here/publish.sh" --dry-run "$work/repo" mybucket https://s3.example.invalid myprefix)
|
||||||
|
assert_contains "dry-run names the destination bucket and prefix" "$out" "s3://mybucket/myprefix"
|
||||||
|
assert_contains "dry-run names the endpoint" "$out" "--endpoint-url https://s3.example.invalid"
|
||||||
|
assert_contains "dry-run goes through with-nixpkgs.sh, same as every other script" "$out" "with-nixpkgs.sh"
|
||||||
|
if echo "$out" | grep -q -- --delete; then
|
||||||
|
fail "dry-run does not silently add --delete (it did!)"
|
||||||
|
else
|
||||||
|
pass "dry-run does not silently add --delete"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- --dry-run with no prefix omits the trailing slash entirely ---"
|
||||||
|
out_noprefix=$(bash "$here/publish.sh" --dry-run "$work/repo" mybucket https://s3.example.invalid)
|
||||||
|
assert_contains "no-prefix dry-run targets the bucket root" "$out_noprefix" "s3://mybucket "
|
||||||
|
|
||||||
|
echo "--- a real invocation fails on the network call, not on arguments ---"
|
||||||
|
# port 1 is a reserved, unassigned TCP port -- nothing listens there, so this
|
||||||
|
# never leaves the loopback interface and needs no real network access.
|
||||||
|
real_out=$(bash "$here/publish.sh" "$work/repo" mybucket "http://127.0.0.1:1" 2>&1) && rc=0 || rc=$?
|
||||||
|
if [ "${rc:-0}" -ne 0 ]; then pass "publish.sh against a closed port exits non-zero"; else
|
||||||
|
fail "publish.sh against a closed port exits non-zero (got 0)"
|
||||||
|
fi
|
||||||
|
assert_contains "the failure is a connection error, not an argument error" "$real_out" "Could not connect"
|
||||||
|
|
||||||
|
report_and_exit
|
||||||
Executable
+91
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# End-to-end test of sign-apk.sh: not just check_zip_alignment.py in
|
||||||
|
# isolation, but the whole script as a caller would invoke it -- a throwaway
|
||||||
|
# keystore generated fresh in a temp dir, a fixture APK, the real `apksigner`
|
||||||
|
# resolved through with-nixpkgs.sh/nixpkgs-pin.
|
||||||
|
#
|
||||||
|
# Needs the toolchain with-nixpkgs.sh pins (apksigner, and a JDK for
|
||||||
|
# `keytool` to generate the throwaway keystore) to already be resolvable --
|
||||||
|
# from the local nix store if this pin's packages were fetched before (as
|
||||||
|
# they were while developing this repo), otherwise from cache.nixos.org over
|
||||||
|
# the network. It does not need any *other* network access: no live host,
|
||||||
|
# no credential, nothing beyond the nixpkgs substituter.
|
||||||
|
set -euo pipefail
|
||||||
|
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
source "$here/test_lib.sh"
|
||||||
|
|
||||||
|
work=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
cd "$work"
|
||||||
|
|
||||||
|
echo "generating throwaway keystore..."
|
||||||
|
bash "$here/with-nixpkgs.sh" jdk21 keytool -genkeypair \
|
||||||
|
-keystore app.jks -storepass apppass123 -keypass apppass123 \
|
||||||
|
-alias appkey -keyalg RSA -keysize 2048 -validity 1 \
|
||||||
|
-dname "CN=Test, OU=Test, O=Test, L=Test, S=Test, C=US" >/dev/null 2>&1
|
||||||
|
assert_file_exists "throwaway keystore was created" app.jks
|
||||||
|
|
||||||
|
echo "building fixture APKs..."
|
||||||
|
# python3 is not assumed to be on PATH -- the real runner this repo targets
|
||||||
|
# has none either (weblib-ci's own README says so), so even fixture-building
|
||||||
|
# goes through the same pin the scripts under test use.
|
||||||
|
bash "$here/with-nixpkgs.sh" python3 python3 - "$work" <<'PYEOF'
|
||||||
|
import struct, sys, zipfile
|
||||||
|
|
||||||
|
def add_stored(zf, name, data, align=None):
|
||||||
|
info = zipfile.ZipInfo(name)
|
||||||
|
info.compress_type = zipfile.ZIP_STORED
|
||||||
|
if align:
|
||||||
|
header_len = 30 + len(name.encode())
|
||||||
|
start = zf.fp.tell() + header_len
|
||||||
|
pad = (-start) % align
|
||||||
|
if pad:
|
||||||
|
if pad < 4:
|
||||||
|
pad += align
|
||||||
|
extra_len = pad - 4
|
||||||
|
info.extra = struct.pack("<HH", 0xD935, extra_len) + b"\x00" * extra_len
|
||||||
|
zf.writestr(info, data)
|
||||||
|
|
||||||
|
work = sys.argv[1]
|
||||||
|
with zipfile.ZipFile(f"{work}/aligned.apk", "w") as zf:
|
||||||
|
add_stored(zf, "AndroidManifest.xml", b"0123456789", 4)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(f"{work}/misaligned.apk", "w") as zf:
|
||||||
|
add_stored(zf, "AndroidManifest.xml", b"0123456789")
|
||||||
|
PYEOF
|
||||||
|
assert_file_exists "aligned fixture built" aligned.apk
|
||||||
|
assert_file_exists "misaligned fixture built (fault to inject)" misaligned.apk
|
||||||
|
|
||||||
|
export APP_KS_PASS=apppass123
|
||||||
|
|
||||||
|
echo "--- fault: signing the misaligned fixture must be refused ---"
|
||||||
|
assert_failure "sign-apk.sh refuses a misaligned APK" \
|
||||||
|
bash "$here/sign-apk.sh" misaligned.apk misaligned-signed.apk app.jks appkey APP_KS_PASS \
|
||||||
|
--min-sdk-version 21 --max-sdk-version 34
|
||||||
|
if [ ! -e misaligned-signed.apk ]; then
|
||||||
|
pass "no signed output was produced for the misaligned fixture"
|
||||||
|
else
|
||||||
|
fail "no signed output was produced for the misaligned fixture (misaligned-signed.apk exists!)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- revert: the same content, properly aligned, must sign and verify ---"
|
||||||
|
assert_success "sign-apk.sh signs the aligned APK" \
|
||||||
|
bash "$here/sign-apk.sh" aligned.apk aligned-signed.apk app.jks appkey APP_KS_PASS \
|
||||||
|
--min-sdk-version 21 --max-sdk-version 34
|
||||||
|
assert_file_exists "signed APK was produced" aligned-signed.apk
|
||||||
|
|
||||||
|
echo "--- independent verification, not just trusting sign-apk.sh's own exit code ---"
|
||||||
|
verify_out=$(bash "$here/with-nixpkgs.sh" apksigner apksigner verify --print-certs \
|
||||||
|
--min-sdk-version 21 --max-sdk-version 34 aligned-signed.apk 2>&1) && rc=0 || rc=$?
|
||||||
|
if [ "${rc:-1}" -eq 0 ]; then pass "apksigner verify (run independently) passes"; else
|
||||||
|
fail "apksigner verify (run independently) passes"; echo "$verify_out" >&2
|
||||||
|
fi
|
||||||
|
assert_contains "verify output names the certificate we signed with" "$verify_out" "CN=Test"
|
||||||
|
|
||||||
|
echo "--- missing password env var is refused, not silently empty ---"
|
||||||
|
assert_failure "sign-apk.sh refuses when the password env var is unset" \
|
||||||
|
env -u APP_KS_PASS -u WRONG_VAR bash "$here/sign-apk.sh" aligned.apk out2.apk app.jks appkey WRONG_VAR \
|
||||||
|
--min-sdk-version 21 --max-sdk-version 34
|
||||||
|
|
||||||
|
report_and_exit
|
||||||
Executable
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Offline tests for with-nixpkgs.sh's fail-loud paths. Deliberately does not
|
||||||
|
# exercise the happy path here -- resolving a real `github:nixos/nixpkgs/...`
|
||||||
|
# reference needs network (or an already-warm local cache) the same way
|
||||||
|
# weblib-ci's own with-nixpkgs.sh does, and weblib-ci does not carry an
|
||||||
|
# automated test for that half either. It was measured by hand instead (see
|
||||||
|
# the PR description) by running `with-nixpkgs.sh apksigner apksigner
|
||||||
|
# version` and `with-nixpkgs.sh "fdroidserver jdk21 apksigner" bash -c
|
||||||
|
# 'which fdroid jarsigner apksigner'` and reading the resolved store paths
|
||||||
|
# back.
|
||||||
|
#
|
||||||
|
# What *is* fully offline and belongs in an automated suite: every way this
|
||||||
|
# script is supposed to refuse rather than silently fall back to an unpinned
|
||||||
|
# `nix shell nixpkgs#foo` -- none of these reach `nix` at all, so they run
|
||||||
|
# with no network and no local nix store required.
|
||||||
|
set -euo pipefail
|
||||||
|
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
source "$here/test_lib.sh"
|
||||||
|
|
||||||
|
work=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
# --- too few arguments -------------------------------------------------
|
||||||
|
assert_failure "too few args exits non-zero" \
|
||||||
|
bash "$here/with-nixpkgs.sh" onlyoneattr
|
||||||
|
|
||||||
|
# --- missing pin file ---------------------------------------------------
|
||||||
|
mkdir -p "$work/no-pin"
|
||||||
|
cp "$here/with-nixpkgs.sh" "$work/no-pin/"
|
||||||
|
out=$(bash "$work/no-pin/with-nixpkgs.sh" apksigner true 2>&1) && rc=0 || rc=$?
|
||||||
|
if [ "${rc:-0}" -ne 0 ]; then pass "missing pin exits non-zero"; else fail "missing pin exits non-zero (got 0)"; fi
|
||||||
|
assert_contains "missing pin names the problem, not a stack trace" "$out" "no $work/no-pin/nixpkgs-pin"
|
||||||
|
assert_contains "missing pin says it refuses to fall back" "$out" "refusing to fall back"
|
||||||
|
|
||||||
|
# --- malformed pin (not 40 hex chars) -----------------------------------
|
||||||
|
mkdir -p "$work/bad-pin"
|
||||||
|
cp "$here/with-nixpkgs.sh" "$work/bad-pin/"
|
||||||
|
echo "not-a-revision" > "$work/bad-pin/nixpkgs-pin"
|
||||||
|
out=$(bash "$work/bad-pin/with-nixpkgs.sh" apksigner true 2>&1) && rc=0 || rc=$?
|
||||||
|
if [ "${rc:-0}" -ne 0 ]; then pass "malformed pin exits non-zero"; else fail "malformed pin exits non-zero (got 0)"; fi
|
||||||
|
assert_contains "malformed pin names what's wrong" "$out" "does not hold a 40-character"
|
||||||
|
|
||||||
|
# --- a well-formed pin is accepted up to the point of invoking nix ------
|
||||||
|
# (proves the regex/parsing logic itself, without needing nix on PATH or
|
||||||
|
# reachable: a bogus command name still gets past validation and only fails
|
||||||
|
# once nix actually tries to run it.)
|
||||||
|
mkdir -p "$work/good-pin"
|
||||||
|
cp "$here/with-nixpkgs.sh" "$work/good-pin/"
|
||||||
|
echo "0123456789abcdef0123456789abcdef01234567" > "$work/good-pin/nixpkgs-pin"
|
||||||
|
out=$(bash "$work/good-pin/with-nixpkgs.sh" apksigner true 2>&1) && rc=0 || rc=$?
|
||||||
|
# whatever happens next is nix's business (missing binary, no network,
|
||||||
|
# etc) -- the point is it got *past* this script's own validation, i.e. it
|
||||||
|
# must not repeat either of the loud-failure messages above.
|
||||||
|
if [[ "$out" != *"refusing to fall back"* ]] && [[ "$out" != *"does not hold a 40-character"* ]]; then
|
||||||
|
pass "well-formed pin passes this script's own validation"
|
||||||
|
else
|
||||||
|
fail "well-formed pin passes this script's own validation (got: $out)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
report_and_exit
|
||||||
Executable
+72
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Run a command with one or more nixpkgs packages on PATH, pinned to the
|
||||||
|
# revision recorded in `nixpkgs-pin`, next to this script.
|
||||||
|
#
|
||||||
|
# bash with-nixpkgs.sh apksigner apksigner verify --print-certs app.apk
|
||||||
|
# bash with-nixpkgs.sh "fdroidserver jdk21 apksigner" fdroid update
|
||||||
|
#
|
||||||
|
# A space-separated list of attributes puts all of them on PATH at once, via
|
||||||
|
# multiple `nix shell` installables in one call -- `fdroid update` shells out
|
||||||
|
# to `jarsigner`/`keytool` (JDK) and `apksigner` itself, so a single package
|
||||||
|
# is not always enough.
|
||||||
|
#
|
||||||
|
# **Call it as `bash <script>`, not `<script>`.** The runner has no
|
||||||
|
# `/usr/bin/env`, so the shebang cannot be relied on there -- the same trap
|
||||||
|
# weblib-ci's with-nixpkgs.sh documents.
|
||||||
|
#
|
||||||
|
# ## Why this is not a copy of weblib-ci's with-nixpkgs.sh
|
||||||
|
#
|
||||||
|
# That script reads *the consuming repo's* `./flake.lock`, relative to the
|
||||||
|
# working directory, so the pin travels with whatever project vendors it.
|
||||||
|
# This repo has no flake.lock of its own -- deliberately; see weblib-ci#9,
|
||||||
|
# which is the same tension one repo over: a `flake.lock` would exist only to
|
||||||
|
# hold one 40-character string, for a repo whose README says it is *run*, not
|
||||||
|
# built. And per fdroid-repo-ci#1, the repo that consumes this one holds
|
||||||
|
# signing keys and is not guaranteed to be a flake at all, so reading a
|
||||||
|
# caller's flake.lock the way weblib-ci's script does would not even find one
|
||||||
|
# to read here.
|
||||||
|
#
|
||||||
|
# So the pin travels with *this* repo instead, in a file named `nixpkgs-pin`
|
||||||
|
# on purpose: it is the exact fallback weblib-ci#9 proposes adding to the
|
||||||
|
# shared script (`./flake.lock` present -> today's behaviour there; else
|
||||||
|
# `./nixpkgs-pin` -> a bare rev; else fail loudly). If that lands, this repo
|
||||||
|
# already speaks its format and nothing here needs to change.
|
||||||
|
#
|
||||||
|
# `fdroidserver`'s output feeds a *signed index* (fdroid-repo-ci#1), so a
|
||||||
|
# drifting nixpkgs is a worse trade here than for a disposable test job --
|
||||||
|
# unlike weblib-ci#9's stdlib-only 1.2s suite, this pin is load-bearing. A
|
||||||
|
# missing or malformed pin is therefore a hard failure, never a silent fall
|
||||||
|
# back to `nix shell nixpkgs#foo`, which would re-resolve the registry and
|
||||||
|
# fetch whatever the channel currently points at -- measured to cost an
|
||||||
|
# unpacked channel tarball in weblib-ci's own job log.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
pin="$here/nixpkgs-pin"
|
||||||
|
|
||||||
|
if [ "$#" -lt 2 ]; then
|
||||||
|
echo "usage: $0 <nixpkgs attribute(s), space-separated> <command> [args...]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
attrs=$1
|
||||||
|
shift
|
||||||
|
|
||||||
|
if [ ! -f "$pin" ]; then
|
||||||
|
echo "$0: no $pin -- refusing to fall back to an unpinned nix shell nixpkgs#<attr>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rev=$(tr -d ' \t\n' < "$pin")
|
||||||
|
if ! [[ "$rev" =~ ^[0-9a-f]{40}$ ]]; then
|
||||||
|
echo "$0: $pin does not hold a 40-character git revision (got '$rev')" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
refs=()
|
||||||
|
for attr in $attrs; do
|
||||||
|
refs+=("github:nixos/nixpkgs/${rev}#${attr}")
|
||||||
|
done
|
||||||
|
|
||||||
|
exec nix shell "${refs[@]}" --command "$@"
|
||||||
Reference in New Issue
Block a user