diff --git a/.gitignore b/.gitignore index 9cab10f..9f7903d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /vendor/ /vendor.lit-bak/ /dist/ +/.gitea-token .phpunit.result.cache diff --git a/bin/release-check.sh b/bin/release-check.sh index 7cc974f..ade91ba 100755 --- a/bin/release-check.sh +++ b/bin/release-check.sh @@ -3,9 +3,9 @@ # # bin/release-check.sh [version] default: Plugin::VERSION # -# Fails unless releases/latest is that version, ZIP and .sig are public downloads below the repository's release -# URL (the only place GiteaUpdater accepts a package from), and the downloaded ZIP passes the signature check against -# the key built into the plugin. +# Fails unless releases/latest is that version, the ZIP is a public download below the repository's release URL (the +# only place GiteaUpdater accepts a package from), and the downloaded ZIP is the plugin in that version with its +# autoloader. # # Environment: LIT_MCP_GITEA (default https://git.loosen-it.de), only to try the script against another server. set -euo pipefail @@ -25,18 +25,15 @@ latest="$(curl -fsS "$gitea/api/v1/repos/$repo/releases/latest")" \ tag="$(jq -r .tag_name <<<"$latest")" [[ "$tag" == "v$version" ]] || fail "releases/latest liefert '$tag', erwartet v$version" -asset_url() { jq -r --arg n "$1" '.assets[] | select(.name == $n) | .browser_download_url' <<<"$latest"; } -zip_url="$(asset_url "$name")" -sig_url="$(asset_url "$name.sig")" -for url in "$zip_url" "$sig_url"; do - [[ "$url" == "$gitea/$repo/releases/download/"* ]] || fail "Asset-URL fehlt oder liegt woanders: '$url'" -done +zip_url="$(jq -r --arg n "$name" '.assets[] | select(.name == $n) | .browser_download_url' <<<"$latest")" +[[ "$zip_url" == "$gitea/$repo/releases/download/"* ]] || fail "Asset-URL fehlt oder liegt woanders: '$zip_url'" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT curl -fsSL "$zip_url" -o "$work/$name" || fail "ZIP nicht ohne Anmeldung ladbar: $zip_url" -curl -fsSL "$sig_url" -o "$work/$name.sig" || fail "Signatur nicht ohne Anmeldung ladbar: $sig_url" -php bin/release-sign.php verify "$work/$name" "$work/$name.sig" >/dev/null \ - || fail "Das heruntergeladene Release besteht die Signaturprüfung nicht" +entries="$(bsdtar -tf "$work/$name")" || fail "Das heruntergeladene ZIP ist kein gültiges ZIP" +grep -qx 'wp-lit-mcp/vendor/autoload_packages.php' <<<"$entries" || fail "Im ZIP fehlt der Autoloader (vendor/)" +[[ "$(bsdtar -xOf "$work/$name" wp-lit-mcp/wp-lit-mcp.php | sed -n 's/^ \* Version: //p')" == "$version" ]] \ + || fail "Das ZIP enthält nicht Version $version" -echo "Release v$version in Ordnung: öffentlich ladbar, Signatur gültig" +echo "Release v$version in Ordnung: öffentlich ladbar, Plugin und Autoloader im ZIP" diff --git a/bin/release-publish.sh b/bin/release-publish.sh new file mode 100755 index 0000000..1120a38 --- /dev/null +++ b/bin/release-publish.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Publishes a release built by bin/release.sh: draft -> ZIP attached and checked -> published -> checked from outside +# (bin/release-check.sh). +# +# bin/release-publish.sh [version] default: Plugin::VERSION +# +# The release only turns public once the ZIP hangs on it, so no customer site ever sees a release without its +# package. An existing draft for the tag (created by hand or by an earlier run) is reused; a published one is refused. +# An existing tag fixes the commit (a release recreated after deleting it), a new tag points at HEAD. +# +# Needs: curl, jq, bsdtar and a Gitea token with write access to the repository (see bin/token.sh). +# Environment: LIT_MCP_GITEA (default https://git.loosen-it.de), only to try the script against another server. +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root" + +gitea="${LIT_MCP_GITEA:-https://git.loosen-it.de}" +api="$gitea/api/v1/repos/janniclas.loosen/WP-LIT-MCP" +version="${1:-$(sed -n "s/.*const VERSION = '\(.*\)';/\1/p" src/Plugin.php)}" +name="wp-lit-mcp-$version.zip" +zip="dist/$name" + +fail() { echo "ABBRUCH: $*" >&2; exit 1; } +step() { echo "==> $*"; } + +source "$root/bin/token.sh" +load_token +[[ -r "$zip" ]] || fail "dist/ hat nicht $name: erst bin/release.sh" + +# A stale file in dist/ is the cheapest way to ship the wrong release. +step "ZIP prüfen" +[[ "$(bsdtar -xOf "$zip" wp-lit-mcp/wp-lit-mcp.php | sed -n 's/^ \* Version: //p')" == "$version" ]] \ + || fail "Das ZIP enthält nicht Version $version" + +auth=(-H "Authorization: token $GITEA_TOKEN") +json=(-H 'Content-Type: application/json') +api_get() { curl -fsS "${auth[@]}" "$api/$1"; } + +step "Entwurf v$version suchen oder anlegen" +release="$(api_get 'releases?per_page=50' | jq -c --arg t "v$version" '[.[] | select(.tag_name == $t)][0] // empty')" +if [[ -z "$release" ]]; then + notes="$(awk -v header="= $version =" '$0 == header { on = 1; next } /^= / { on = 0 } on' readme.txt)" + target="$(curl -sS "${auth[@]}" "$api/tags/v$version" | jq -r '.commit.sha // empty')" + [[ -n "$target" ]] || target="$(git rev-parse HEAD)" + request="$(jq -n --arg tag "v$version" --arg sha "$target" --arg body "$notes" \ + '{tag_name: $tag, target_commitish: $sha, name: $tag, body: $body, draft: true}')" + release="$(curl -fsS "${auth[@]}" "${json[@]}" -d "$request" "$api/releases")" || fail "Release ließ sich nicht anlegen" +fi +[[ "$(jq -r .draft <<<"$release")" == "true" ]] || fail "v$version ist schon veröffentlicht" +id="$(jq -r .id <<<"$release")" + +step "ZIP anhängen" +old="$(jq -r --arg n "$name" '.assets[]? | select(.name == $n) | .id' <<<"$release")" +[[ -z "$old" ]] || curl -fsS -X DELETE "${auth[@]}" "$api/releases/$id/assets/$old" >/dev/null +curl -fsS "${auth[@]}" -F "attachment=@$zip" "$api/releases/$id/assets?name=$name" >/dev/null \ + || fail "Upload von $name fehlgeschlagen" + +# Read it back instead of trusting the upload: only a draft that really carries the ZIP goes public. +step "Anhang nachprüfen" +size="$(api_get "releases/$id" | jq -r --arg n "$name" '.assets[]? | select(.name == $n) | .size')" +[[ "$size" == "$(stat -c %s "$zip")" ]] || fail "$name hängt nicht oder mit falscher Größe am Entwurf (Gitea: '$size')" + +step "Veröffentlichen" +curl -fsS -X PATCH "${auth[@]}" "${json[@]}" -d '{"draft": false}' "$api/releases/$id" >/dev/null \ + || fail "Veröffentlichen fehlgeschlagen" + +step "Ergebnis von außen prüfen" +exec bash bin/release-check.sh "$version" diff --git a/bin/release-sign.php b/bin/release-sign.php deleted file mode 100644 index 2fc9587..0000000 --- a/bin/release-sign.php +++ /dev/null @@ -1,64 +0,0 @@ - new key pair; prints the public key for PackageSignature - * php bin/release-sign.php sign prints the signature (the .sig asset) - * php bin/release-sign.php verify checks against the key built into the plugin - */ - -declare(strict_types=1); - -// The class guards against direct web access; this is the command line. -define('ABSPATH', __DIR__ . '/'); -require_once __DIR__ . '/../src/Updater/PackageSignature.php'; - -use LIT\MCP\Updater\PackageSignature; - -function fail(string $message): never -{ - fwrite(STDERR, $message . "\n"); - exit(1); -} - -[, $command, $first, $second] = $argv + [null, '', '', '']; - -switch ($command) { - case 'keygen': - if ($first === '' || file_exists($first)) { - fail('usage: keygen (an existing file is never overwritten)'); - } - $pair = sodium_crypto_sign_keypair(); - if (! is_dir(dirname($first)) && ! mkdir(dirname($first), 0700, true)) { - fail('cannot create ' . dirname($first)); - } - // 0600 before the secret is written, so it is never readable by others. - touch($first); - chmod($first, 0600); - file_put_contents($first, base64_encode(sodium_crypto_sign_secretkey($pair))); - echo base64_encode(sodium_crypto_sign_publickey($pair)), "\n"; - break; - - case 'sign': - $secret = is_readable($second) ? base64_decode(trim((string) file_get_contents($second)), true) : false; - if (! is_readable($first) || $secret === false || strlen($secret) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) { - fail('usage: sign (readable ZIP, key file from keygen)'); - } - $message = PackageSignature::message(basename($first), $first); - echo base64_encode(sodium_crypto_sign_detached($message, $secret)), "\n"; - break; - - case 'verify': - if (! is_readable($first) || ! is_readable($second)) { - fail('usage: verify '); - } - if (! (new PackageSignature())->verifies(basename($first), $first, (string) file_get_contents($second))) { - fail('signature does not verify against the key built into the plugin'); - } - echo "ok\n"; - break; - - default: - fail('usage: keygen | sign | verify '); -} diff --git a/bin/release.sh b/bin/release.sh index 5236736..057198d 100755 --- a/bin/release.sh +++ b/bin/release.sh @@ -1,20 +1,17 @@ #!/usr/bin/env bash -# Builds and signs a WP-LIT-MCP release from the committed state. It publishes nothing: tag, Gitea release and the -# upload of the two files in dist/ are done afterwards, then bin/release-check.sh looks at the result. +# Builds a WP-LIT-MCP release ZIP from the committed state into dist/. It publishes nothing: bin/ship.sh does the +# whole way (this script, push, release, upload, check from outside). # # bin/release.sh # -# Needs: bsdtar, composer, php (sodium). The signing key (php bin/release-sign.php keygen) lives outside the -# repository. -# -# Environment: LIT_MCP_RELEASE_KEY (default ~/.config/wp-lit-mcp/release.key), LIT_MCP_DIVI_CHECK (command that must -# succeed before a release, default: ddev wp --user=janniclas lit-mcp divi-check). +# Needs: bsdtar, composer. +# Environment: LIT_MCP_DIVI_CHECK (command that must succeed before a release, default: +# ddev wp --user=janniclas lit-mcp divi-check). set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$root" -key="${LIT_MCP_RELEASE_KEY:-$HOME/.config/wp-lit-mcp/release.key}" divi_check="${LIT_MCP_DIVI_CHECK:-ddev wp --user=janniclas lit-mcp divi-check}" fail() { echo "ABBRUCH: $*" >&2; exit 1; } @@ -29,7 +26,6 @@ version="$(sed -n "s/.*const VERSION = '\(.*\)';/\1/p" src/Plugin.php)" grep -q "^= $version =\$" readme.txt || fail "readme.txt hat keinen Changelog-Eintrag für $version" [[ -z "$(git status --porcelain)" ]] || fail "Arbeitsbaum nicht sauber: erst committen, das ZIP entsteht aus HEAD" ! git rev-parse -q --verify "refs/tags/v$version" >/dev/null || fail "Tag v$version existiert schon" -[[ -r "$key" ]] || fail "Signierschlüssel fehlt: $key (php bin/release-sign.php keygen )" # --- 2. Tests and the Divi check must be green ------------------------------------------------------------------------ step "Tests" @@ -46,8 +42,9 @@ git archive HEAD | tar -x -C "$work/wp-lit-mcp" (cd "$work/wp-lit-mcp" && composer install --no-dev --optimize-autoloader --no-interaction --quiet) name="wp-lit-mcp-$version.zip" mkdir -p dist +# Files of earlier releases would be one wrong click away from the upload. +rm -f dist/wp-lit-mcp-* zip="$root/dist/$name" -rm -f "$zip" "$zip.sig" (cd "$work" && bsdtar --format zip -cf "$zip" wp-lit-mcp) # --- 4. Look into the ZIP like WordPress will: one folder, the plugin, its autoloader, the right version -------------- @@ -57,18 +54,10 @@ if grep -qv '^wp-lit-mcp/' <<<"$entries"; then fail "ZIP enthält Einträge auß for need in wp-lit-mcp/wp-lit-mcp.php wp-lit-mcp/vendor/autoload_packages.php; do grep -qx "$need" <<<"$entries" || fail "Im ZIP fehlt $need" done -if grep -qE '^wp-lit-mcp/(tests|bin|docs/superpowers|\.git)(/|$)' <<<"$entries"; then +if grep -qE '^wp-lit-mcp/(tests|bin|docs/superpowers|\.git|\.gitea-token)(/|$)' <<<"$entries"; then fail "Entwicklungsdateien im ZIP" fi [[ "$(bsdtar -xOf "$zip" wp-lit-mcp/wp-lit-mcp.php | sed -n 's/^ \* Version: //p')" == "$version" ]] \ || fail "Version im ZIP stimmt nicht mit $version überein" -# --- 5. Sign, and prove the signature against the key built into the plugin ------------------------------------------- -step "Signieren" -php bin/release-sign.php sign "$zip" "$key" > "$zip.sig" -php bin/release-sign.php verify "$zip" "$zip.sig" >/dev/null \ - || fail "Signatur passt nicht zum Schlüssel im Plugin (PackageSignature::RELEASE_KEY)" - -echo "In Ordnung: dist/$name und dist/$name.sig" -echo "Weiter: Tag v$version und Release auf Gitea anlegen, beide Dateien hochladen, veröffentlichen." -echo "Danach: bin/release-check.sh $version" +echo "In Ordnung: dist/$name" diff --git a/bin/ship.sh b/bin/ship.sh new file mode 100755 index 0000000..891b66b --- /dev/null +++ b/bin/ship.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# One command for a release: build (tests, Divi check, ZIP), push main, publish, check from outside. +# +# bin/ship.sh +# +# Run it when Plugin::VERSION is bumped and committed (release.sh refuses a dirty tree or an existing tag). The token +# comes from $GITEA_TOKEN or a file (see bin/token.sh). +# +# Environment: LIT_MCP_GITEA (default https://git.loosen-it.de), only to try the script against another server. +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root" + +gitea="${LIT_MCP_GITEA:-https://git.loosen-it.de}" +repo="janniclas.loosen/WP-LIT-MCP" + +fail() { echo "ABBRUCH: $*" >&2; exit 1; } +step() { echo "==> $*"; } + +source "$root/bin/token.sh" +load_token +export GITEA_TOKEN +[[ "$(git rev-parse --abbrev-ref HEAD)" == "main" ]] || fail "Releases gehen nur von main aus" + +bash bin/release.sh + +# Push only if Gitea does not have HEAD yet. The token travels in a header of this one call, via the environment: not +# in the URL, not in the git configuration, not in the process list. +remote_head="$(curl -fsS "$gitea/api/v1/repos/$repo/branches/main" | jq -r .commit.id)" +if [[ "$remote_head" != "$(git rev-parse HEAD)" ]]; then + step "main pushen" + basic="$(printf 'janniclas.loosen:%s' "$GITEA_TOKEN" | base64 -w0)" + GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: Basic $basic" \ + git push "$gitea/$repo.git" HEAD:main || fail "Push fehlgeschlagen" +fi + +bash bin/release-publish.sh diff --git a/bin/token.sh b/bin/token.sh new file mode 100644 index 0000000..0ac99af --- /dev/null +++ b/bin/token.sh @@ -0,0 +1,17 @@ +# Loads the Gitea token into GITEA_TOKEN: the environment first, then .gitea-token in the repository (gitignored), +# then ~/.config/wp-lit-mcp/gitea-token. Sourced by bin/ship.sh and bin/release-publish.sh, which define $root and fail(). + +load_token() { + local file + for file in "$root/.gitea-token" "$HOME/.config/wp-lit-mcp/gitea-token"; do + [[ -z "${GITEA_TOKEN:-}" && -r "$file" ]] || continue + # A token file the repository would commit, or that others can read, is refused before it is used. + if [[ "$file" == "$root/.gitea-token" ]]; then + git -C "$root" check-ignore -q .gitea-token || fail ".gitea-token steht nicht in .gitignore" + fi + [[ "$(stat -c %a "$file")" =~ ^[0-7]00$ ]] || fail "$file ist für andere lesbar: chmod 600" + GITEA_TOKEN="$(<"$file")" + done + [[ -n "${GITEA_TOKEN:-}" ]] \ + || fail "Kein Token: GITEA_TOKEN setzen oder .gitea-token im Repo (alternativ ~/.config/wp-lit-mcp/gitea-token) anlegen" +} diff --git a/readme.txt b/readme.txt index e0f6190..54b77a4 100644 --- a/readme.txt +++ b/readme.txt @@ -200,8 +200,7 @@ Bibliothekseintrags. Neue Versionen erscheinen als Release auf git.loosen-it.de; WordPress zeigt sie unter Plugins und Dashboard → Aktualisierungen an wie jedes andere Plugin. Automatisch einspielen lässt sich ein Update mit dem Schalter „Automatische Updates aktivieren“ in der Plugin-Liste. Ein Update ersetzt den Plugin-Ordner; ein Entwicklungs- -Checkout mit .git-Ordner bekommt deshalb kein Update angeboten. Jedes Paket ist signiert (Ed25519); WordPress -installiert nur, was zur Signatur im Plugin passt. Versionen vor 1.0.1 kennen diesen +Checkout mit .git-Ordner bekommt deshalb kein Update angeboten. Versionen vor 1.0.1 kennen diesen Weg nicht: der Wechsel auf 1.0.1 ist einmalig von Hand (ZIP hochladen), danach läuft es über WordPress. == Divi-Freigabe == @@ -211,10 +210,7 @@ LIT MCP schreibt nur mit Divi-Versionen, die für diese Plugin-Version geprüft == Changelog == = 1.0.1 = -* Erstes vollständig ausgeliefertes Release: 1.0.0 wurde ohne Paketdatei veröffentlicht. Inhalt unverändert. - -= 1.0.0 = -* Updates über WordPress: Das neueste Release von git.loosen-it.de erscheint als Plugin-Update (Update URI); automatisches Einspielen über den Schalter in der Plugin-Liste. Der Wechsel auf 1.0.0 ist einmalig von Hand. +* Updates über WordPress: Das neueste Release von git.loosen-it.de erscheint als Plugin-Update (Update URI); automatisches Einspielen über den Schalter in der Plugin-Liste. Der Wechsel auf 1.0.1 ist einmalig von Hand. (Version 1.0.0 wurde nie ausgeliefert.) = 0.7.0 = * Designsystem schreiben (Etappe 4): Presets lesen, anlegen, ändern, löschen, einzelnen Modulen und ganzen Seiten zuweisen und Verweise überall entfernen (Probelauf, Bestätigung, Snapshot, Wiederherstellen). diff --git a/src/Plugin.php b/src/Plugin.php index d528769..a919ba8 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -38,7 +38,6 @@ use LIT\MCP\Server\RequestContext; use LIT\MCP\Server\ServerRegistrar; use LIT\MCP\Server\VisibilityFilter; use LIT\MCP\Updater\GiteaUpdater; -use LIT\MCP\Updater\PackageSignature; defined('ABSPATH') || exit; @@ -57,7 +56,7 @@ final class Plugin self::$pluginFile = $pluginFile; // Before the dependency check: a plugin that is inactive for a missing dependency must still be updatable. - (new GiteaUpdater(plugin_basename($pluginFile), dirname($pluginFile), new PackageSignature()))->register(); + (new GiteaUpdater(plugin_basename($pluginFile), dirname($pluginFile)))->register(); $missing = (new Dependencies())->missing(); if ($missing !== []) { diff --git a/src/Updater/GiteaUpdater.php b/src/Updater/GiteaUpdater.php index b3e2c61..4429636 100644 --- a/src/Updater/GiteaUpdater.php +++ b/src/Updater/GiteaUpdater.php @@ -13,11 +13,10 @@ defined('ABSPATH') || exit; * Gitea host, so WordPress asks the filter update_plugins_ (about every 12 hours, which is also the only * caching needed). The release carries the ZIP as asset wp-lit-mcp-.zip. * - * The ZIP is only installed with a valid signature (PackageSignature, asset .sig); a hijacked Gitea account - * alone cannot ship code. - * - * shortcut: public release assets only (WordPress downloads without credentials), a private repository would - * need a token in the downloads. + * shortcut: no signature – a package is trusted as far as the repository's release downloads and TLS are, so whoever + * can publish a release there ships code to every site that updates. Public release assets only (WordPress downloads + * without credentials). Add a signature checked before unpacking (key outside Gitea) before the plugin runs on sites + * you do not control. */ final class GiteaUpdater { @@ -29,55 +28,18 @@ final class GiteaUpdater private const SLUG = 'wp-lit-mcp'; /** - * @param string $pluginBasename plugin_basename() of the main file, the only plugin answered for. - * @param string $pluginDir The plugin's folder; an update replaces it as a whole. - * @param PackageSignature $signature Checks the downloaded ZIP before WordPress unpacks it. + * @param string $pluginBasename plugin_basename() of the main file, the only plugin answered for. + * @param string $pluginDir The plugin's folder; an update replaces it as a whole. */ public function __construct( private string $pluginBasename, - private string $pluginDir, - private PackageSignature $signature + private string $pluginDir ) { } public function register(): void { add_filter('update_plugins_' . self::HOST, [$this, 'offer'], 10, 3); - add_filter('upgrader_pre_download', [$this, 'verifyDownload'], 10, 2); - } - - /** - * Downloads our package itself to check its signature before WordPress unpacks a byte. Fails closed: a missing - * or wrong signature file stops the update. - * - * @param mixed $reply What an earlier filter decided; false = WordPress downloads as usual. - * @return mixed The downloaded file's path, a WP_Error, or $reply for any package that is not ours. - */ - public function verifyDownload(mixed $reply, string $package): mixed - { - if ($reply !== false || ! str_starts_with($package, self::RELEASE_DOWNLOADS)) { - return $reply; - } - - $file = download_url($package); - if (is_wp_error($file)) { - return $file; - } - - // The signature is 89 bytes; never read more of an answer than that file can be. - $response = wp_safe_remote_get($package . '.sig', ['timeout' => 10, 'limit_response_size' => 1024]); - $signature = wp_remote_retrieve_body($response); - if (! $this->signature->verifies(basename($package), $file, $signature)) { - wp_delete_file($file); - Log::failure('update', 'signature of ' . basename($package) . ' is missing or does not verify'); - - return new \WP_Error( - 'lit_mcp_bad_signature', - __('Die Signatur des Plugin-Pakets stimmt nicht. Das Update wurde nicht installiert.', 'wp-lit-mcp') - ); - } - - return $file; } /** diff --git a/src/Updater/PackageSignature.php b/src/Updater/PackageSignature.php deleted file mode 100644 index 573e5f2..0000000 --- a/src/Updater/PackageSignature.php +++ /dev/null @@ -1,45 +0,0 @@ -publicKey, true); - if ( - $signature === false || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES - || $key === false || strlen($key) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES - || ! is_readable($zipPath) - ) { - return false; - } - - return sodium_crypto_sign_verify_detached($signature, self::message($assetName, $zipPath), $key); - } -} diff --git a/tests/Unit/Updater/GiteaUpdaterTest.php b/tests/Unit/Updater/GiteaUpdaterTest.php index 48069dc..eab6b98 100644 --- a/tests/Unit/Updater/GiteaUpdaterTest.php +++ b/tests/Unit/Updater/GiteaUpdaterTest.php @@ -8,7 +8,6 @@ use Brain\Monkey\Filters; use Brain\Monkey\Functions; use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Updater\GiteaUpdater; -use LIT\MCP\Updater\PackageSignature; use Mockery; use WP_Error; @@ -20,40 +19,26 @@ final class GiteaUpdaterTest extends TestCase private string $logFile = ''; private string|false $previousLog = ''; - private string $secretKey = ''; - private string $publicKey = ''; - /** @var list */ - private array $tempFiles = []; protected function setUp(): void { parent::setUp(); - Functions\stubTranslationFunctions(); // error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead. - $this->logFile = $this->tempFile(); + $this->logFile = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); $this->previousLog = ini_set('error_log', $this->logFile); - - $pair = sodium_crypto_sign_keypair(); - $this->secretKey = sodium_crypto_sign_secretkey($pair); - $this->publicKey = base64_encode(sodium_crypto_sign_publickey($pair)); } protected function tearDown(): void { ini_set('error_log', (string) $this->previousLog); - foreach ($this->tempFiles as $file) { - if (file_exists($file)) { - unlink($file); - } - } + unlink($this->logFile); parent::tearDown(); } - public function testRegistersItsFilters(): void + public function testRegistersItselfForTheGiteaHost(): void { $updater = $this->updater(); Filters\expectAdded('update_plugins_' . GiteaUpdater::HOST)->once()->with([$updater, 'offer'], 10, 3); - Filters\expectAdded('upgrader_pre_download')->once()->with([$updater, 'verifyDownload'], 10, 2); $updater->register(); } @@ -84,8 +69,7 @@ final class GiteaUpdaterTest extends TestCase Functions\expect('wp_remote_get')->never(); try { - $updater = new GiteaUpdater(self::BASENAME, $dir, new PackageSignature($this->publicKey)); - self::assertFalse($updater->offer(false, [], self::BASENAME)); + self::assertFalse((new GiteaUpdater(self::BASENAME, $dir))->offer(false, [], self::BASENAME)); } finally { rmdir($dir . '/.git'); rmdir($dir); @@ -136,81 +120,10 @@ final class GiteaUpdaterTest extends TestCase ]; } - public function testDownloadWithAValidSignatureIsHandedToWordPress(): void - { - $zip = $this->tempFile('release bytes'); - $this->givenDownload($zip, $this->sign(self::ZIP_NAME, $zip)); - - self::assertSame($zip, $this->updater()->verifyDownload(false, self::ZIP)); - self::assertFileExists($zip); - } - - public function testDownloadWithAForeignSignatureIsDeletedAndRefused(): void - { - $zip = $this->tempFile('release bytes'); - $other = $this->tempFile('other bytes'); - $this->givenDownload($zip, $this->sign(self::ZIP_NAME, $other)); - - $result = $this->updater()->verifyDownload(false, self::ZIP); - - self::assertInstanceOf(WP_Error::class, $result); - self::assertFileDoesNotExist($zip); - self::assertStringContainsString('update failed: signature of ' . self::ZIP_NAME, $this->log()); - } - - public function testSignatureOfAnotherVersionDoesNotPassAsThisOne(): void - { - $zip = $this->tempFile('old release bytes'); - $this->givenDownload($zip, $this->sign('wp-lit-mcp-0.9.0.zip', $zip)); - - self::assertInstanceOf(WP_Error::class, $this->updater()->verifyDownload(false, self::ZIP)); - } - - public function testDownloadWithoutSignatureFileIsRefused(): void - { - $zip = $this->tempFile('release bytes'); - $this->givenDownload($zip, ''); - - self::assertInstanceOf(WP_Error::class, $this->updater()->verifyDownload(false, self::ZIP)); - self::assertFileDoesNotExist($zip); - } - - public function testFailedDownloadIsReturnedAsIs(): void - { - $error = new WP_Error('download_failed', 'cURL error 28'); - Functions\expect('download_url')->once()->with(self::ZIP)->andReturn($error); - - self::assertSame($error, $this->updater()->verifyDownload(false, self::ZIP)); - } - - public function testOtherPackagesAndEarlierDecisionsAreNotTouched(): void - { - Functions\expect('download_url')->never(); - - self::assertFalse($this->updater()->verifyDownload(false, 'https://downloads.wordpress.org/plugin/x.zip')); - self::assertSame('/tmp/earlier.zip', $this->updater()->verifyDownload('/tmp/earlier.zip', self::ZIP)); - } - private function updater(): GiteaUpdater { // __DIR__ has no .git folder. - return new GiteaUpdater(self::BASENAME, __DIR__, new PackageSignature($this->publicKey)); - } - - private function tempFile(string $contents = ''): string - { - $file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-test'); - file_put_contents($file, $contents); - $this->tempFiles[] = $file; - - return $file; - } - - private function sign(string $assetName, string $zipPath): string - { - $message = PackageSignature::message($assetName, $zipPath); - - return base64_encode(sodium_crypto_sign_detached($message, $this->secretKey)); + return new GiteaUpdater(self::BASENAME, __DIR__); } private function log(): string @@ -218,18 +131,6 @@ final class GiteaUpdaterTest extends TestCase return (string) file_get_contents($this->logFile); } - private function givenDownload(string $zip, string $signatureBody): void - { - Functions\expect('download_url')->once()->with(self::ZIP)->andReturn($zip); - Functions\expect('wp_safe_remote_get')->once() - ->with(self::ZIP . '.sig', Mockery::type('array')) - ->andReturn(['body' => $signatureBody]); - Functions\when('wp_remote_retrieve_body')->justReturn($signatureBody); - Functions\when('wp_delete_file')->alias(static function (string $file): void { - unlink($file); - }); - } - private function givenRelease(int $status, string $body): void { $latestRelease = Mockery::pattern('#^https://git\.loosen-it\.de/api/v1/repos/.+/releases/latest$#'); diff --git a/tests/Unit/Updater/PackageSignatureTest.php b/tests/Unit/Updater/PackageSignatureTest.php deleted file mode 100644 index 7acb41d..0000000 --- a/tests/Unit/Updater/PackageSignatureTest.php +++ /dev/null @@ -1,91 +0,0 @@ -secretKey = sodium_crypto_sign_secretkey($pair); - $this->signature = new PackageSignature(base64_encode(sodium_crypto_sign_publickey($pair))); - $this->zip = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-sig'); - file_put_contents($this->zip, 'zip bytes'); - } - - protected function tearDown(): void - { - unlink($this->zip); - } - - public function testASignatureOverNameAndContentVerifies(): void - { - $signature = $this->sign('wp-lit-mcp-1.0.0.zip'); - - self::assertTrue($this->signature->verifies('wp-lit-mcp-1.0.0.zip', $this->zip, $signature)); - } - - public function testAnotherAssetNameDoesNotVerify(): void - { - $signature = $this->sign('wp-lit-mcp-1.0.0.zip'); - - self::assertFalse($this->signature->verifies('wp-lit-mcp-1.0.1.zip', $this->zip, $signature)); - } - - public function testChangedContentDoesNotVerify(): void - { - $signature = $this->sign('x.zip'); - file_put_contents($this->zip, 'changed bytes'); - - self::assertFalse($this->signature->verifies('x.zip', $this->zip, $signature)); - } - - public function testAnotherKeyDoesNotVerify(): void - { - $foreign = new PackageSignature(base64_encode(sodium_crypto_sign_publickey(sodium_crypto_sign_keypair()))); - - self::assertFalse($foreign->verifies('x.zip', $this->zip, $this->sign('x.zip'))); - } - - /** @dataProvider malformedInput */ - public function testMalformedInputFailsClosed(string $signature, string $publicKey, string $zipPath): void - { - $subject = new PackageSignature($publicKey === 'real' ? $this->realKey() : $publicKey); - - self::assertFalse($subject->verifies('x.zip', $zipPath === 'zip' ? $this->zip : $zipPath, $signature)); - } - - /** @return array */ - public static function malformedInput(): array - { - return [ - 'signature is no base64' => ['not base64 !!', 'real', 'zip'], - 'signature is empty' => ['', 'real', 'zip'], - 'signature has the wrong length' => [base64_encode('short'), 'real', 'zip'], - 'no key configured' => ['AAAA', '', 'zip'], - 'key has the wrong length' => ['AAAA', base64_encode('short'), 'zip'], - 'file is missing' => ['AAAA', 'real', '/nonexistent/x.zip'], - ]; - } - - private function sign(string $assetName): string - { - $message = PackageSignature::message($assetName, $this->zip); - - return base64_encode(sodium_crypto_sign_detached($message, $this->secretKey)); - } - - private function realKey(): string - { - return base64_encode(sodium_crypto_sign_publickey(sodium_crypto_sign_keypair())); - } -}