From 3899c652b6465cf8b1891e512b1a80ce548f2d1c Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 12:30:39 +0200 Subject: [PATCH 01/23] =?UTF-8?q?wawp-updater=20:=20configuration=20et=20v?= =?UTF-8?q?alidation=20stricte=20des=20r=C3=A9ponses=20du=20serveur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Support/Http.php | 34 ++++ tests/Unit/ValidatorTest.php | 97 +++++++++++ tests/bootstrap.php | 8 + tests/wp-stubs.php | 230 +++++++++++++++++++++++++++ wawp-updater/src/class-config.php | 51 ++++++ wawp-updater/src/class-validator.php | 198 +++++++++++++++++++++++ 6 files changed, 618 insertions(+) create mode 100644 tests/Support/Http.php create mode 100644 tests/Unit/ValidatorTest.php create mode 100644 tests/wp-stubs.php create mode 100644 wawp-updater/src/class-config.php create mode 100644 wawp-updater/src/class-validator.php diff --git a/tests/Support/Http.php b/tests/Support/Http.php new file mode 100644 index 0000000..d9a8391 --- /dev/null +++ b/tests/Support/Http.php @@ -0,0 +1,34 @@ + */ + public static function response(int $code, string $body): array + { + return ['response' => ['code' => $code], 'body' => $body]; + } + + /** @param array> $offers */ + public static function offer_body(array $offers): string + { + return (string) json_encode(['plugins' => (object) $offers], JSON_UNESCAPED_SLASHES); + } + + /** @return array */ + public static function offer(string $version, string $signature, string $token = 'abc.def'): array + { + return [ + 'version' => $version, + 'requires' => '6.4', + 'tested' => '6.8', + 'requires_php' => '7.4', + 'last_updated' => '2026-10-12', + 'package' => 'https://updates.wearewp.pro/v1/download/' . $token, + 'signature' => $signature, + ]; + } +} diff --git a/tests/Unit/ValidatorTest.php b/tests/Unit/ValidatorTest.php new file mode 100644 index 0000000..a145a98 --- /dev/null +++ b/tests/Unit/ValidatorTest.php @@ -0,0 +1,97 @@ + Http::offer('1.4.0', self::signature())]); + $offers = Validator::check_response($body, ['wawp-demo', 'wawp-autre']); + self::assertNotNull($offers); + self::assertSame('1.4.0', $offers['wawp-demo']['version']); + self::assertSame('https://updates.wearewp.pro/v1/download/abc.def', $offers['wawp-demo']['package']); + self::assertSame('7.4', $offers['wawp-demo']['requires_php']); + } + + public function testEmptyListAndEmptyObjectAreBothAccepted(): void + { + self::assertSame([], Validator::check_response('{"plugins":{}}', ['wawp-demo'])); + self::assertSame([], Validator::check_response('{"plugins":[]}', ['wawp-demo'])); + } + + public function testAnyAnomalyRejectsTheWholeResponse(): void + { + $good = Http::offer('1.4.0', self::signature()); + $cases = [ + 'pas du JSON' => 'nope', + 'sans plugins' => '{"status":"ok"}', + 'slug non demandé' => Http::offer_body(['wawp-intrus' => $good]), + 'version invalide' => Http::offer_body(['wawp-demo' => ['version' => '1.4.0-beta'] + $good]), + 'hôte de paquet étranger' => Http::offer_body(['wawp-demo' => ['package' => 'https://evil.example/v1/download/abc'] + $good]), + 'chemin de paquet détourné' => Http::offer_body(['wawp-demo' => ['package' => 'https://updates.wearewp.pro/v1/download/../x'] + $good]), + 'préfixe trompeur' => Http::offer_body(['wawp-demo' => ['package' => 'https://updates.wearewp.pro.evil.example/v1/download/abc'] + $good]), + 'signature trop courte' => Http::offer_body(['wawp-demo' => ['signature' => base64_encode('court')] + $good]), + 'signature non base64' => Http::offer_body(['wawp-demo' => ['signature' => '***'] + $good]), + 'tested invalide' => Http::offer_body(['wawp-demo' => ['tested' => 'latest'] + $good]), + 'date invalide' => Http::offer_body(['wawp-demo' => ['last_updated' => 'hier'] + $good]), + ]; + foreach ($cases as $label => $body) { + self::assertNull(Validator::check_response($body, ['wawp-demo']), $label); + } + } + + public function testInfoResponse(): void + { + $body = (string) json_encode([ + 'name' => 'WAWP Démo', + 'slug' => 'wawp-demo', + 'version' => '1.4.0', + 'requires' => '6.4', + 'tested' => '6.8', + 'requires_php' => '7.4', + 'last_updated' => '2026-10-12', + 'sections' => ['description' => '

Desc

', 'changelog' => '

1.4.0

'], + ]); + $info = Validator::info_response($body, 'wawp-demo'); + self::assertNotNull($info); + self::assertSame('WAWP Démo', $info['name']); + self::assertSame('

1.4.0

', $info['sections']['changelog']); + + self::assertNull(Validator::info_response($body, 'wawp-autre'), 'slug différent'); + self::assertNull(Validator::info_response(str_replace('WAWP D\u00e9mo', '', $body), 'wawp-demo'), 'nom vide'); + self::assertNull(Validator::info_response('{"name":"x"}', 'wawp-demo'), 'incomplet'); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 53ab368..b7a198a 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -3,3 +3,11 @@ declare(strict_types=1); require dirname(__DIR__) . '/vendor/autoload.php'; +require __DIR__ . '/wp-stubs.php'; + +foreach (['config', 'validator', 'signature', 'client', 'updater'] as $class) { + $file = dirname(__DIR__) . "/wawp-updater/src/class-$class.php"; + if (is_file($file)) { + require_once $file; + } +} diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php new file mode 100644 index 0000000..1d58821 --- /dev/null +++ b/tests/wp-stubs.php @@ -0,0 +1,230 @@ +> */ + public static array $hooks = []; + /** @var array */ + public static array $transients = []; + /** @var array */ + public static array $options = []; + /** @var callable|null */ + public static $http = null; + /** @var callable|null */ + public static $download = null; + /** @var list}> */ + public static array $requests = []; + public static bool $can = true; + public static ?object $screen = null; + /** @var array */ + public static array $versions = []; + public static bool $multisite = false; + + public static function reset(): void + { + self::$hooks = []; + self::$transients = []; + self::$options = []; + self::$http = null; + self::$download = null; + self::$requests = []; + self::$can = true; + self::$screen = null; + self::$versions = []; + self::$multisite = false; + } +} + +class WP_Error +{ + public string $code; + public string $message; + + public function __construct(string $code = '', string $message = '') + { + $this->code = $code; + $this->message = $message; + } + + public function get_error_code(): string + { + return $this->code; + } + + public function get_error_message(): string + { + return $this->message; + } +} + +function add_filter(string $hook, callable $callback, int $priority = 10, int $args = 1): bool +{ + WawpTestState::$hooks[$hook][] = $callback; + + return true; +} + +function add_action(string $hook, callable $callback, int $priority = 10, int $args = 1): bool +{ + return add_filter($hook, $callback, $priority, $args); +} + +function plugin_basename(string $file): string +{ + return ltrim(str_replace(WP_PLUGIN_DIR, '', str_replace('\\', '/', $file)), '/'); +} + +/** @param array $headers @return array */ +function get_file_data(string $file, array $headers): array +{ + return ['Version' => WawpTestState::$versions[$file] ?? '']; +} + +function get_site_transient(string $key): mixed +{ + return isset(WawpTestState::$transients[$key]) ? WawpTestState::$transients[$key]['value'] : false; +} + +function set_site_transient(string $key, mixed $value, int $ttl = 0): bool +{ + WawpTestState::$transients[$key] = ['value' => $value, 'ttl' => $ttl]; + + return true; +} + +function delete_site_transient(string $key): bool +{ + unset(WawpTestState::$transients[$key]); + + return true; +} + +function get_site_option(string $key, mixed $default = false): mixed +{ + return WawpTestState::$options[$key] ?? $default; +} + +function update_site_option(string $key, mixed $value): bool +{ + WawpTestState::$options[$key] = $value; + + return true; +} + +function delete_site_option(string $key): bool +{ + unset(WawpTestState::$options['site:' . $key], WawpTestState::$options[$key]); + + return true; +} + +function delete_option(string $key): bool +{ + unset(WawpTestState::$options[$key]); + + return true; +} + +function is_multisite(): bool +{ + return WawpTestState::$multisite; +} + +function home_url(): string +{ + return 'https://client.fr'; +} + +function network_home_url(): string +{ + return 'https://reseau-client.fr'; +} + +function get_bloginfo(string $show): string +{ + return '6.8.1'; +} + +function wp_json_encode(mixed $data): string|false +{ + return json_encode($data); +} + +/** @param array $args @return array|WP_Error */ +function wp_safe_remote_post(string $url, array $args): array|WP_Error +{ + WawpTestState::$requests[] = ['POST', $url, $args]; + + return (WawpTestState::$http)('POST', $url, $args); +} + +/** @param array $args @return array|WP_Error */ +function wp_safe_remote_get(string $url, array $args): array|WP_Error +{ + WawpTestState::$requests[] = ['GET', $url, $args]; + + return (WawpTestState::$http)('GET', $url, $args); +} + +function is_wp_error(mixed $thing): bool +{ + return $thing instanceof WP_Error; +} + +function wp_remote_retrieve_response_code(mixed $response): int|string +{ + return is_array($response) ? ($response['response']['code'] ?? '') : ''; +} + +function wp_remote_retrieve_body(mixed $response): string +{ + return is_array($response) ? (string) ($response['body'] ?? '') : ''; +} + +function download_url(string $url, int $timeout = 300): string|WP_Error +{ + return (WawpTestState::$download)($url); +} + +function wp_delete_file(string $file): void +{ + if (is_file($file)) { + unlink($file); + } +} + +function wp_kses_post(string $html): string +{ + return strip_tags($html, '

  • '); +} + +function wp_strip_all_tags(string $text): string +{ + return strip_tags($text); +} + +function __(string $text, string $domain = 'default'): string +{ + return $text; +} + +function esc_html__(string $text, string $domain = 'default'): string +{ + return htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); +} + +function current_user_can(string $capability): bool +{ + return WawpTestState::$can; +} + +function get_current_screen(): ?object +{ + return WawpTestState::$screen; +} diff --git a/wawp-updater/src/class-config.php b/wawp-updater/src/class-config.php new file mode 100644 index 0000000..3b5f769 --- /dev/null +++ b/wawp-updater/src/class-config.php @@ -0,0 +1,51 @@ + + */ + const PUBLIC_KEYS = array(); + + /** Cache de la réponse /v1/check (transient de site). */ + const CACHE_KEY = 'wawp_updater_check'; + + /** Préfixe du cache des réponses /v1/info. */ + const INFO_CACHE_PREFIX = 'wawp_updater_info_'; + + /** Durée du cache après succès (12 h). */ + const CACHE_TTL = 43200; + + /** Durée du cache après échec (1 h), pour ne pas inonder le serveur. */ + const ERROR_TTL = 3600; + + /** Délai réseau des appels à l'API. */ + const TIMEOUT = 10; + + /** Délai de téléchargement d'un paquet. */ + const DOWNLOAD_TIMEOUT = 300; +} diff --git a/wawp-updater/src/class-validator.php b/wawp-updater/src/class-validator.php new file mode 100644 index 0000000..3fbd228 --- /dev/null +++ b/wawp-updater/src/class-validator.php @@ -0,0 +1,198 @@ +|null + */ + public static function check_response( $body, array $expected_slugs ): ?array { + $data = self::decode( $body ); + if ( null === $data || ! isset( $data['plugins'] ) || ! is_array( $data['plugins'] ) ) { + return null; + } + + $offers = array(); + foreach ( $data['plugins'] as $slug => $offer ) { + $slug = (string) $slug; + if ( ! self::is_slug( $slug ) || ! in_array( $slug, $expected_slugs, true ) || ! is_array( $offer ) ) { + return null; + } + $version = $offer['version'] ?? null; + $package = $offer['package'] ?? null; + $signature = $offer['signature'] ?? null; + $requires = $offer['requires'] ?? null; + $tested = $offer['tested'] ?? null; + $requires_php = $offer['requires_php'] ?? null; + $last_updated = $offer['last_updated'] ?? null; + if ( ! self::matches( self::VERSION, $version ) + || ! self::is_package_url( $package ) + || ! self::is_signature( $signature ) + || ! self::optional( self::VERSION, $requires ) + || ! self::optional( self::VERSION, $tested ) + || ! self::optional( self::VERSION, $requires_php ) + || ! self::optional( self::DATE, $last_updated ) ) { + return null; + } + $offers[ $slug ] = array( + 'version' => $version, + 'requires' => $requires, + 'tested' => $tested, + 'requires_php' => $requires_php, + 'last_updated' => $last_updated, + 'package' => $package, + 'signature' => $signature, + ); + } + + return $offers; + } + + /** + * Valide le corps d'une réponse /v1/info. + * + * @param mixed $body Corps brut. + * @param string $slug Slug demandé. + * @return array{name: string, slug: string, version: string, requires: ?string, tested: ?string, requires_php: ?string, last_updated: ?string, sections: array{description: string, changelog: string}}|null + */ + public static function info_response( $body, string $slug ): ?array { + $data = self::decode( $body ); + if ( null === $data ) { + return null; + } + $name = $data['name'] ?? null; + $version = $data['version'] ?? null; + $requires = $data['requires'] ?? null; + $tested = $data['tested'] ?? null; + $requires_php = $data['requires_php'] ?? null; + $last_updated = $data['last_updated'] ?? null; + $sections = $data['sections'] ?? null; + if ( ! self::matches( self::NAME, $name ) + || ( $data['slug'] ?? null ) !== $slug + || ! self::matches( self::VERSION, $version ) + || ! self::optional( self::VERSION, $requires ) + || ! self::optional( self::VERSION, $tested ) + || ! self::optional( self::VERSION, $requires_php ) + || ! self::optional( self::DATE, $last_updated ) + || ! is_array( $sections ) ) { + return null; + } + $description = $sections['description'] ?? null; + $changelog = $sections['changelog'] ?? null; + if ( ! is_string( $description ) || ! is_string( $changelog ) || strlen( $description ) > self::MAX_SECTION || strlen( $changelog ) > self::MAX_SECTION ) { + return null; + } + + return array( + 'name' => $name, + 'slug' => $slug, + 'version' => $version, + 'requires' => $requires, + 'tested' => $tested, + 'requires_php' => $requires_php, + 'last_updated' => $last_updated, + 'sections' => array( + 'description' => $description, + 'changelog' => $changelog, + ), + ); + } + + /** + * @param mixed $body Corps brut. + * @return array|null + */ + private static function decode( $body ): ?array { + if ( ! is_string( $body ) || strlen( $body ) > self::MAX_BODY ) { + return null; + } + $data = json_decode( $body, true, 8 ); + + return is_array( $data ) ? $data : null; + } + + /** + * @param mixed $value Valeur à tester. + */ + private static function is_package_url( $value ): bool { + $prefix = Config::ENDPOINT . '/v1/download/'; + + return is_string( $value ) + && 0 === strpos( $value, $prefix ) + && self::matches( self::TOKEN, (string) substr( $value, strlen( $prefix ) ) ); + } + + /** + * @param mixed $value Valeur à tester. + */ + private static function is_signature( $value ): bool { + if ( ! is_string( $value ) ) { + return false; + } + $raw = base64_decode( $value, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- décodage d'une signature binaire. + + return false !== $raw && 64 === strlen( $raw ); + } + + /** + * @param string $pattern Motif. + * @param mixed $value Valeur à tester. + */ + private static function optional( string $pattern, $value ): bool { + return null === $value || self::matches( $pattern, $value ); + } + + /** + * @param string $pattern Motif. + * @param mixed $value Valeur à tester. + */ + private static function matches( string $pattern, $value ): bool { + return is_string( $value ) && 1 === preg_match( $pattern, $value ); + } +} From 20d4c041b0ae049f09c797462410a7f48eaa2cf0 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 12:32:27 +0200 Subject: [PATCH 02/23] =?UTF-8?q?wawp-updater=20:=20v=C3=A9rification=20Ed?= =?UTF-8?q?25519=20des=20paquets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Support/Keys.php | 26 ++++++++++++++ tests/Unit/SignatureTest.php | 53 ++++++++++++++++++++++++++++ wawp-updater/src/class-signature.php | 48 +++++++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 tests/Support/Keys.php create mode 100644 tests/Unit/SignatureTest.php create mode 100644 wawp-updater/src/class-signature.php diff --git a/tests/Support/Keys.php b/tests/Support/Keys.php new file mode 100644 index 0000000..a70f63f --- /dev/null +++ b/tests/Support/Keys.php @@ -0,0 +1,26 @@ + base64_encode(sodium_crypto_sign_secretkey($pair)), + 'public' => base64_encode(sodium_crypto_sign_publickey($pair)), + ]; + } + + public static function sign_file(string $path, string $secret_b64): string + { + $secret = (string) base64_decode($secret_b64, true); + + return base64_encode(sodium_crypto_sign_detached((string) hash_file('sha384', $path, true), $secret)); + } +} diff --git a/tests/Unit/SignatureTest.php b/tests/Unit/SignatureTest.php new file mode 100644 index 0000000..c282b81 --- /dev/null +++ b/tests/Unit/SignatureTest.php @@ -0,0 +1,53 @@ +file = sys_get_temp_dir() . '/wawp-sig-' . bin2hex(random_bytes(4)) . '.zip'; + file_put_contents($this->file, random_bytes(2048)); + } + + protected function tearDown(): void + { + if (is_file($this->file)) { + unlink($this->file); + } + } + + public function testActiveAndBackupKeysAreAccepted(): void + { + $active = Keys::pair(); + $backup = Keys::pair(); + $keys = [$active['public'], $backup['public']]; + + self::assertTrue(Signature::verify_file($this->file, Keys::sign_file($this->file, $active['secret']), $keys)); + self::assertTrue(Signature::verify_file($this->file, Keys::sign_file($this->file, $backup['secret']) . "\n", $keys)); + } + + public function testEverythingElseIsRefused(): void + { + $known = Keys::pair(); + $signature = Keys::sign_file($this->file, $known['secret']); + + self::assertFalse(Signature::verify_file($this->file, Keys::sign_file($this->file, Keys::pair()['secret']), [$known['public']]), 'clé inconnue'); + self::assertFalse(Signature::verify_file($this->file, $signature, []), 'aucune clé configurée'); + self::assertFalse(Signature::verify_file($this->file, $signature, ['pas-une-cle', base64_encode('court')]), 'clés malformées'); + self::assertFalse(Signature::verify_file($this->file, 'pas du base64 !', [$known['public']]), 'signature illisible'); + self::assertFalse(Signature::verify_file($this->file, base64_encode('court'), [$known['public']]), 'signature trop courte'); + self::assertFalse(Signature::verify_file($this->file . '.absent', $signature, [$known['public']]), 'fichier absent'); + + file_put_contents($this->file, 'x', FILE_APPEND); + self::assertFalse(Signature::verify_file($this->file, $signature, [$known['public']]), 'fichier modifié'); + } +} diff --git a/wawp-updater/src/class-signature.php b/wawp-updater/src/class-signature.php new file mode 100644 index 0000000..b90ba6c --- /dev/null +++ b/wawp-updater/src/class-signature.php @@ -0,0 +1,48 @@ + Date: Mon, 12 Oct 2026 12:34:03 +0200 Subject: [PATCH 03/23] wawp-updater : appels HTTP /v1/check et /v1/info --- tests/Unit/ClientTest.php | 74 +++++++++++++++++++++++++++ wawp-updater/src/class-client.php | 83 +++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 tests/Unit/ClientTest.php create mode 100644 wawp-updater/src/class-client.php diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php new file mode 100644 index 0000000..e1fccfc --- /dev/null +++ b/tests/Unit/ClientTest.php @@ -0,0 +1,74 @@ + Http::response(200, Http::offer_body(['wawp-demo' => Http::offer('1.4.0', $signature)])); + + $offers = (new Client())->check(self::KEY, 'https://client.fr', ['wawp-demo' => '1.3.0']); + + self::assertNotNull($offers); + self::assertSame('1.4.0', $offers['wawp-demo']['version']); + [$method, $url, $args] = WawpTestState::$requests[0]; + self::assertSame('POST', $method); + self::assertSame('https://updates.wearewp.pro/v1/check', $url); + self::assertSame('Bearer ' . self::KEY, $args['headers']['Authorization']); + self::assertSame(10, $args['timeout']); + self::assertSame(0, $args['redirection']); + self::assertTrue($args['sslverify']); + self::assertSame( + ['site' => 'https://client.fr', 'plugins' => ['wawp-demo' => '1.3.0'], 'wp' => '6.8.1', 'php' => PHP_VERSION, 'client' => '1.0.0'], + json_decode($args['body'], true) + ); + self::assertStringNotContainsString(self::KEY, $args['user-agent']); + } + + public function testCheckReturnsNullOnErrorStatusOrInvalidBody(): void + { + $client = new Client(); + foreach ([ + static fn () => new WP_Error('http_request_failed', 'timeout'), + static fn (): array => Http::response(401, '{"status":"unauthorized"}'), + static fn (): array => Http::response(200, 'pas du JSON'), + ] as $http) { + WawpTestState::$http = $http; + self::assertNull($client->check(self::KEY, 'https://client.fr', ['wawp-demo' => '1.3.0'])); + } + } + + public function testInfoSendsSiteHeaderAndValidatesTheAnswer(): void + { + WawpTestState::$http = static fn (): array => Http::response(200, (string) json_encode([ + 'name' => 'WAWP Démo', 'slug' => 'wawp-demo', 'version' => '1.4.0', 'requires' => null, 'tested' => null, + 'requires_php' => null, 'last_updated' => null, 'sections' => ['description' => '', 'changelog' => ''], + ])); + + $info = (new Client())->info(self::KEY, 'https://client.fr', 'wawp-demo'); + + self::assertNotNull($info); + [$method, $url, $args] = WawpTestState::$requests[0]; + self::assertSame('GET', $method); + self::assertSame('https://updates.wearewp.pro/v1/info/wawp-demo', $url); + self::assertSame('https://client.fr', $args['headers']['X-WAWP-Site']); + self::assertSame('Bearer ' . self::KEY, $args['headers']['Authorization']); + } +} diff --git a/wawp-updater/src/class-client.php b/wawp-updater/src/class-client.php new file mode 100644 index 0000000..39e2d6f --- /dev/null +++ b/wawp-updater/src/class-client.php @@ -0,0 +1,83 @@ + $installed Slug vers version installée. + * @return array|null + */ + public function check( string $key, string $site, array $installed ): ?array { + $response = wp_safe_remote_post( + Config::ENDPOINT . '/v1/check', + array( + 'timeout' => Config::TIMEOUT, + 'redirection' => 0, + 'sslverify' => true, + 'user-agent' => 'wawp-updater/' . Config::VERSION . '; ' . $site, + 'headers' => array( + 'Authorization' => 'Bearer ' . $key, + 'Content-Type' => 'application/json', + ), + 'body' => (string) wp_json_encode( + array( + 'site' => $site, + 'plugins' => (object) $installed, + 'wp' => (string) get_bloginfo( 'version' ), + 'php' => PHP_VERSION, + 'client' => Config::VERSION, + ) + ), + ) + ); + if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { + return null; + } + + return Validator::check_response( wp_remote_retrieve_body( $response ), array_map( 'strval', array_keys( $installed ) ) ); + } + + /** + * Interroge /v1/info pour la fenêtre « Afficher les détails ». + * + * @param string $key Clé de licence. + * @param string $site Adresse du site. + * @param string $slug Slug du plugin. + * @return array{name: string, slug: string, version: string, requires: ?string, tested: ?string, requires_php: ?string, last_updated: ?string, sections: array{description: string, changelog: string}}|null + */ + public function info( string $key, string $site, string $slug ): ?array { + $response = wp_safe_remote_get( + Config::ENDPOINT . '/v1/info/' . rawurlencode( $slug ), + array( + 'timeout' => Config::TIMEOUT, + 'redirection' => 0, + 'sslverify' => true, + 'user-agent' => 'wawp-updater/' . Config::VERSION . '; ' . $site, + 'headers' => array( + 'Authorization' => 'Bearer ' . $key, + 'X-WAWP-Site' => $site, + ), + ) + ); + if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { + return null; + } + + return Validator::info_response( wp_remote_retrieve_body( $response ), $slug ); + } +} From 4945809995fb91fc9606073335e62ca71e9683ee Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 12:35:49 +0200 Subject: [PATCH 04/23] =?UTF-8?q?wawp-updater=20:=20points=20d'accroche=20?= =?UTF-8?q?WordPress,=20jeton=20frais=20et=20signature=20au=20t=C3=A9l?= =?UTF-8?q?=C3=A9chargement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Unit/UpdaterKeyTest.php | 57 +++++ tests/Unit/UpdaterTest.php | 186 ++++++++++++++++ wawp-updater/src/class-updater.php | 338 +++++++++++++++++++++++++++++ 3 files changed, 581 insertions(+) create mode 100644 tests/Unit/UpdaterKeyTest.php create mode 100644 tests/Unit/UpdaterTest.php create mode 100644 wawp-updater/src/class-updater.php diff --git a/tests/Unit/UpdaterKeyTest.php b/tests/Unit/UpdaterKeyTest.php new file mode 100644 index 0000000..363de89 --- /dev/null +++ b/tests/Unit/UpdaterKeyTest.php @@ -0,0 +1,57 @@ + */ + public static function invalidKeys(): array + { + return [ + 'absente' => [null], + 'espaces' => [' wawp_' . str_repeat('A', 43) . ' '], + 'entier' => [12345], + 'tronquée' => ['wawp_' . str_repeat('A', 40)], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('invalidKeys')] + public function testInvalidKeyMeansNoRequestAndNotice(mixed $key): void + { + if ($key !== null) { + define('WAWP_LICENSE_KEY', $key); + } + WawpTestState::reset(); + WawpTestState::$versions[self::FILE] = '1.3.0'; + WawpTestState::$http = static function (): array { + throw new \LogicException('aucune requête attendue'); + }; + $updater = new Updater([self::FILE], new Client(), []); + + self::assertFalse($updater->filter_update(false, [], 'wawp-demo/wawp-demo.php', [])); + self::assertSame([], WawpTestState::$requests); + + WawpTestState::$screen = (object) ['id' => 'plugins']; + ob_start(); + $updater->admin_notice(); + $notice = (string) ob_get_clean(); + self::assertStringContainsString('WAWP_LICENSE_KEY', $notice); + self::assertStringContainsString('notice-warning', $notice); + + WawpTestState::$can = false; + ob_start(); + $updater->admin_notice(); + self::assertSame('', ob_get_clean(), 'rien pour un utilisateur sans droit de mise à jour'); + } +} diff --git a/tests/Unit/UpdaterTest.php b/tests/Unit/UpdaterTest.php new file mode 100644 index 0000000..b76f842 --- /dev/null +++ b/tests/Unit/UpdaterTest.php @@ -0,0 +1,186 @@ +pair = Keys::pair(); + $this->zip = sys_get_temp_dir() . '/wawp-upd-' . bin2hex(random_bytes(4)) . '.zip'; + file_put_contents($this->zip, random_bytes(1024)); + $signature = Keys::sign_file($this->zip, $this->pair['secret']); + WawpTestState::$http = function (string $method) use ($signature): array { + $this->token++; + + return $method === 'POST' + ? Http::response(200, Http::offer_body(['wawp-demo' => Http::offer('1.4.0', $signature, 'jeton' . $this->token)])) + : Http::response(200, (string) json_encode([ + 'name' => 'WAWP Démo', 'slug' => 'wawp-demo', 'version' => '1.4.0', 'requires' => '6.4', + 'tested' => '6.8', 'requires_php' => '7.4', 'last_updated' => '2026-10-12', + 'sections' => ['description' => '

    D

    ', 'changelog' => '

    1.4.0

    '], + ])); + }; + $zip = $this->zip; + WawpTestState::$download = static function () use ($zip): string { + $copy = $zip . '.dl'; + copy($zip, $copy); + + return $copy; + }; + } + + protected function tearDown(): void + { + foreach ([$this->zip, $this->zip . '.dl'] as $file) { + if (is_file($file)) { + unlink($file); + } + } + } + + private function updater(?array $keys = null): Updater + { + return new Updater([self::FILE, WP_PLUGIN_DIR . '/mal-range/autre.php'], new Client(), $keys ?? [$this->pair['public']]); + } + + public function testHooksAreRegistered(): void + { + $this->updater()->hooks(); + foreach (['update_plugins_updates.wearewp.pro', 'plugins_api', 'upgrader_pre_download', 'upgrader_process_complete', 'load-update-core.php', 'admin_notices', 'admin_init'] as $hook) { + self::assertArrayHasKey($hook, WawpTestState::$hooks, $hook); + } + } + + public function testUpdateIsOfferedFromOneCachedRequest(): void + { + $updater = $this->updater(); + $update = $updater->filter_update(false, [], self::BASENAME, []); + self::assertIsArray($update); + self::assertSame('1.4.0', $update['version']); + self::assertSame('wawp-demo', $update['slug']); + self::assertSame('https://updates.wearewp.pro/v1/download/jeton1', $update['package']); + + $updater->filter_update(false, [], self::BASENAME, []); + self::assertCount(1, WawpTestState::$requests, 'la seconde lecture vient du cache'); + self::assertSame(43200, WawpTestState::$transients['wawp_updater_check']['ttl']); + self::assertStringNotContainsString(self::KEY, (string) json_encode(WawpTestState::$transients)); + } + + public function testForeignPluginIsLeftAlone(): void + { + self::assertSame('intact', $this->updater()->filter_update('intact', [], 'autre/autre.php', [])); + self::assertSame([], WawpTestState::$requests); + } + + public function testServerFailureIsCachedOneHourAndBreaksNothing(): void + { + WawpTestState::$http = static fn () => new WP_Error('http_request_failed', 'timeout'); + self::assertFalse($this->updater()->filter_update(false, [], self::BASENAME, [])); + self::assertSame(3600, WawpTestState::$transients['wawp_updater_check']['ttl']); + } + + public function testMultisiteSendsNetworkUrl(): void + { + WawpTestState::$multisite = true; + $this->updater()->filter_update(false, [], self::BASENAME, []); + self::assertSame('https://reseau-client.fr', json_decode(WawpTestState::$requests[0][2]['body'], true)['site']); + } + + public function testPreDownloadUsesFreshTokenAndVerifiesSignature(): void + { + $updater = $this->updater(); + $stale = $updater->filter_update(false, [], self::BASENAME, [])['package']; + + $path = $updater->filter_pre_download(false, $stale, null, ['plugin' => self::BASENAME]); + + self::assertSame($this->zip . '.dl', $path); + self::assertCount(2, WawpTestState::$requests, 'un /v1/check frais au téléchargement'); + } + + public function testPreDownloadRefusesBadSignatureAndDeletesTheFile(): void + { + $result = $this->updater([Keys::pair()['public']])->filter_pre_download(false, 'https://updates.wearewp.pro/v1/download/x', null, ['plugin' => self::BASENAME]); + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('wawp_signature_invalid', $result->get_error_code()); + self::assertFileDoesNotExist($this->zip . '.dl'); + + $empty = $this->updater([])->filter_pre_download(false, 'https://updates.wearewp.pro/v1/download/x', null, ['plugin' => self::BASENAME]); + self::assertSame('wawp_signature_invalid', $empty->get_error_code(), 'aucune clé configurée : refus'); + } + + public function testPreDownloadIgnoresForeignPackagesAndRefusesUnknownOnes(): void + { + $updater = $this->updater(); + self::assertFalse($updater->filter_pre_download(false, 'https://downloads.wordpress.org/plugin/x.zip', null, [])); + self::assertSame('deja', $updater->filter_pre_download('deja', 'https://updates.wearewp.pro/v1/download/x', null, [])); + $unknown = $updater->filter_pre_download(false, 'https://updates.wearewp.pro/v1/download/x', null, ['plugin' => 'autre/autre.php']); + self::assertSame('wawp_unknown_package', $unknown->get_error_code()); + } + + public function testPreDownloadReportsDownloadFailureWithoutLeakingTheToken(): void + { + WawpTestState::$download = static fn () => new WP_Error('http_404', 'https://updates.wearewp.pro/v1/download/secret'); + $result = $this->updater()->filter_pre_download(false, 'https://updates.wearewp.pro/v1/download/x', null, ['plugin' => self::BASENAME]); + self::assertSame('wawp_download_failed', $result->get_error_code()); + self::assertStringNotContainsString('download/', $result->get_error_message()); + } + + public function testPluginsApiReturnsSanitizedDetails(): void + { + $args = (object) ['slug' => 'wawp-demo']; + $info = $this->updater()->filter_plugins_api(false, 'plugin_information', $args); + self::assertIsObject($info); + self::assertSame('WAWP Démo', $info->name); + self::assertSame('

    D

    x', $info->sections['description']); + self::assertSame('https://updates.wearewp.pro/v1/info/wawp-demo', WawpTestState::$requests[0][1]); + + self::assertSame('inchangé', $this->updater()->filter_plugins_api('inchangé', 'plugin_information', (object) ['slug' => 'akismet'])); + self::assertSame('inchangé', $this->updater()->filter_plugins_api('inchangé', 'query_plugins', $args)); + } + + public function testCacheFlushAndLegacyCleanup(): void + { + $updater = $this->updater(); + $updater->filter_update(false, [], self::BASENAME, []); + $updater->flush_cache(); + self::assertArrayNotHasKey('wawp_updater_check', WawpTestState::$transients); + + WawpTestState::$options['external_updates-wawp-demo'] = 'ancien cache PUC'; + $updater->cleanup_legacy(); + self::assertArrayNotHasKey('external_updates-wawp-demo', WawpTestState::$options); + self::assertSame(md5('wawp-demo'), WawpTestState::$options['wawp_updater_cleanup']); + } + + public function testNoNoticeWhenTheKeyIsValid(): void + { + WawpTestState::$screen = (object) ['id' => 'plugins']; + ob_start(); + $this->updater()->admin_notice(); + self::assertSame('', ob_get_clean()); + } +} diff --git a/wawp-updater/src/class-updater.php b/wawp-updater/src/class-updater.php new file mode 100644 index 0000000..d6febc3 --- /dev/null +++ b/wawp-updater/src/class-updater.php @@ -0,0 +1,338 @@ + + */ + private $files = array(); + + /** + * Nom de base WordPress (slug/slug.php) vers slug. + * + * @var array + */ + private $basenames = array(); + + /** + * @var Client + */ + private $client; + + /** + * @var string[] + */ + private $public_keys; + + /** + * @param string[] $plugin_files Fichiers principaux inscrits. + * @param Client $client Client HTTP. + * @param string[] $public_keys Clés publiques Ed25519 en base64. + */ + public function __construct( array $plugin_files, Client $client, array $public_keys ) { + $this->client = $client; + $this->public_keys = $public_keys; + foreach ( $plugin_files as $file ) { + $basename = plugin_basename( (string) $file ); + $slug = dirname( $basename ); + if ( Validator::is_slug( $slug ) && $slug . '/' . $slug . '.php' === $basename ) { + $this->files[ $slug ] = (string) $file; + $this->basenames[ $basename ] = $slug; + } + } + } + + /** + * Enregistre les points d'accroche. + */ + public function hooks(): void { + add_filter( 'update_plugins_' . Config::HOST, array( $this, 'filter_update' ), 10, 4 ); + add_filter( 'plugins_api', array( $this, 'filter_plugins_api' ), 10, 3 ); + add_filter( 'upgrader_pre_download', array( $this, 'filter_pre_download' ), 10, 4 ); + add_action( 'upgrader_process_complete', array( $this, 'flush_cache' ), 10, 0 ); + add_action( 'load-update-core.php', array( $this, 'maybe_flush_on_force_check' ) ); + add_action( 'admin_notices', array( $this, 'admin_notice' ) ); + add_action( 'network_admin_notices', array( $this, 'admin_notice' ) ); + add_action( 'admin_init', array( $this, 'cleanup_legacy' ) ); + } + + /** + * Filtre update_plugins_{hôte} : données de mise à jour d'un plugin inscrit. + * + * @param mixed $update Valeur reçue. + * @param mixed $plugin_data En-têtes du plugin. + * @param mixed $plugin_file Nom de base du plugin. + * @param mixed $locales Langues installées. + * @return mixed + */ + public function filter_update( $update, $plugin_data, $plugin_file, $locales ) { + try { + $slug = is_string( $plugin_file ) ? ( $this->basenames[ $plugin_file ] ?? null ) : null; + if ( null === $slug ) { + return $update; + } + $offers = $this->offers( false ); + if ( ! isset( $offers[ $slug ] ) ) { + return $update; + } + $offer = $offers[ $slug ]; + + return array_filter( + array( + 'slug' => $slug, + 'version' => $offer['version'], + 'url' => Config::ENDPOINT, + 'package' => $offer['package'], + 'requires' => $offer['requires'], + 'tested' => $offer['tested'], + 'requires_php' => $offer['requires_php'], + ), + static function ( $value ) { + return null !== $value; + } + ); + } catch ( \Throwable $e ) { + return $update; + } + } + + /** + * Filtre plugins_api : fenêtre « Afficher les détails ». + * + * @param mixed $result Valeur reçue. + * @param mixed $action Action demandée. + * @param mixed $args Arguments de la requête. + * @return mixed + */ + public function filter_plugins_api( $result, $action, $args ) { + try { + if ( 'plugin_information' !== $action || ! is_object( $args ) || ! isset( $args->slug ) || ! is_string( $args->slug ) || ! isset( $this->files[ $args->slug ] ) ) { + return $result; + } + $key = $this->key(); + if ( null === $key ) { + return $result; + } + $slug = $args->slug; + $cache_key = Config::INFO_CACHE_PREFIX . $slug; + $cached = get_site_transient( $cache_key ); + $info = is_string( $cached ) ? Validator::info_response( $cached, $slug ) : null; + if ( null === $info ) { + $info = $this->client->info( $key, $this->site(), $slug ); + if ( null === $info ) { + return $result; + } + set_site_transient( $cache_key, (string) wp_json_encode( $info ), Config::CACHE_TTL ); + } + + return (object) array( + 'name' => wp_strip_all_tags( $info['name'] ), + 'slug' => $slug, + 'version' => $info['version'], + 'requires' => $info['requires'], + 'tested' => $info['tested'], + 'requires_php' => $info['requires_php'], + 'last_updated' => $info['last_updated'], + 'download_link' => '', + 'external' => true, + 'sections' => array( + 'description' => wp_kses_post( $info['sections']['description'] ), + 'changelog' => wp_kses_post( $info['sections']['changelog'] ), + ), + ); + } catch ( \Throwable $e ) { + return $result; + } + } + + /** + * Filtre upgrader_pre_download : jeton frais, téléchargement, vérification de la signature. + * + * @param mixed $reply Valeur reçue (false si personne n'a encore traité le paquet). + * @param mixed $package URL du paquet. + * @param mixed $upgrader Instance de WP_Upgrader. + * @param mixed $hook_extra Contexte de la mise à jour. + * @return mixed Chemin du fichier vérifié, WP_Error, ou la valeur reçue. + */ + public function filter_pre_download( $reply, $package, $upgrader, $hook_extra ) { + if ( false !== $reply || ! is_string( $package ) || 0 !== strpos( $package, Config::ENDPOINT . '/v1/download/' ) ) { + return $reply; + } + try { + $plugin = is_array( $hook_extra ) && isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ? $hook_extra['plugin'] : ''; + $slug = $this->basenames[ $plugin ] ?? null; + if ( null === $slug ) { + return new \WP_Error( 'wawp_unknown_package', __( 'Paquet WeAre[WP] inconnu : mise à jour annulée.', 'wawp-updater' ) ); + } + $offers = $this->offers( true ); + if ( ! isset( $offers[ $slug ] ) ) { + return new \WP_Error( 'wawp_token_unavailable', __( 'Mise à jour WeAre[WP] indisponible : licence refusée ou serveur injoignable.', 'wawp-updater' ) ); + } + $file = download_url( $offers[ $slug ]['package'], Config::DOWNLOAD_TIMEOUT ); + if ( is_wp_error( $file ) || ! is_string( $file ) ) { + return new \WP_Error( 'wawp_download_failed', __( 'Téléchargement du paquet WeAre[WP] impossible.', 'wawp-updater' ) ); + } + if ( ! Signature::verify_file( $file, $offers[ $slug ]['signature'], $this->public_keys ) ) { + wp_delete_file( $file ); + return new \WP_Error( 'wawp_signature_invalid', __( 'Signature du paquet WeAre[WP] invalide : mise à jour annulée.', 'wawp-updater' ) ); + } + + return $file; + } catch ( \Throwable $e ) { + return new \WP_Error( 'wawp_updater_error', __( 'Erreur du client de mise à jour WeAre[WP] : mise à jour annulée.', 'wawp-updater' ) ); + } + } + + /** + * Vide les caches du client. + */ + public function flush_cache(): void { + delete_site_transient( Config::CACHE_KEY ); + foreach ( array_keys( $this->files ) as $slug ) { + delete_site_transient( Config::INFO_CACHE_PREFIX . $slug ); + } + } + + /** + * Vide le cache quand l'administrateur clique « Rechercher les mises à jour ». + */ + public function maybe_flush_on_force_check(): void { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- lecture seule : vide uniquement un cache local. + if ( isset( $_GET['force-check'] ) ) { + $this->flush_cache(); + } + } + + /** + * Avertit les administrateurs, sur l'écran Extensions, si la clé est absente ou invalide. + */ + public function admin_notice(): void { + try { + if ( array() === $this->files || ! current_user_can( 'update_plugins' ) || null !== $this->key() ) { + return; + } + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + if ( null === $screen || ! isset( $screen->id ) || ! in_array( $screen->id, array( 'plugins', 'plugins-network' ), true ) ) { + return; + } + printf( + '

    %s

    ', + esc_html__( 'WeAre[WP] : la constante WAWP_LICENSE_KEY est absente ou invalide dans wp-config.php. Les mises à jour des extensions WeAre[WP] sont désactivées.', 'wawp-updater' ) + ); + } catch ( \Throwable $e ) { + return; + } + } + + /** + * Supprime une fois le cache laissé par Plugin Update Checker (migration). + */ + public function cleanup_legacy(): void { + try { + $flag = md5( implode( ',', array_keys( $this->files ) ) ); + if ( get_site_option( 'wawp_updater_cleanup' ) === $flag ) { + return; + } + foreach ( array_keys( $this->files ) as $slug ) { + delete_option( 'external_updates-' . $slug ); + delete_site_option( 'external_updates-' . $slug ); + } + update_site_option( 'wawp_updater_cleanup', $flag ); + } catch ( \Throwable $e ) { + return; + } + } + + /** + * Offres du serveur pour les plugins inscrits, depuis le cache ou une requête. + * + * @param bool $fresh Ignorer le cache (jeton frais pour un téléchargement). + * @return array + */ + private function offers( bool $fresh ): array { + $key = $this->key(); + if ( null === $key ) { + return array(); + } + $installed = $this->installed(); + if ( array() === $installed ) { + return array(); + } + $site = $this->site(); + $slugs = array_map( 'strval', array_keys( $installed ) ); + $hash = md5( (string) wp_json_encode( array( $site, $installed, Config::VERSION, hash( 'sha256', $key ) ) ) ); + + if ( ! $fresh ) { + $cached = get_site_transient( Config::CACHE_KEY ); + if ( is_array( $cached ) && isset( $cached['hash'], $cached['body'] ) && $hash === $cached['hash'] && is_string( $cached['body'] ) ) { + $offers = Validator::check_response( $cached['body'], $slugs ); + if ( null !== $offers ) { + return $offers; + } + } + } + + $offers = $this->client->check( $key, $site, $installed ); + set_site_transient( + Config::CACHE_KEY, + array( + 'hash' => $hash, + 'body' => (string) wp_json_encode( array( 'plugins' => (object) ( null === $offers ? array() : $offers ) ) ), + ), + null === $offers ? Config::ERROR_TTL : Config::CACHE_TTL + ); + + return null === $offers ? array() : $offers; + } + + /** + * Versions installées des plugins inscrits. + * + * @return array + */ + private function installed(): array { + $installed = array(); + foreach ( $this->files as $slug => $file ) { + $data = get_file_data( $file, array( 'Version' => 'Version' ) ); + $version = $data['Version'] ?? ''; + if ( Validator::is_installed_version( $version ) ) { + $installed[ $slug ] = $version; + } + } + + return $installed; + } + + /** + * Clé de licence lue dans wp-config.php, ou null si absente ou invalide. + */ + private function key(): ?string { + if ( ! defined( 'WAWP_LICENSE_KEY' ) ) { + return null; + } + $key = constant( 'WAWP_LICENSE_KEY' ); + + return Validator::is_key( $key ) ? $key : null; + } + + /** + * Adresse du site envoyée au serveur (celle du réseau en multisite). + */ + private function site(): string { + return is_multisite() ? network_home_url() : home_url(); + } +} From 0bf13b9177ac7cbcad2e22730f112eb73bcacec4 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 12:40:12 +0200 Subject: [PATCH 05/23] =?UTF-8?q?wawp-updater=20:=20vidage=20du=20cache=20?= =?UTF-8?q?avant=20la=20v=C3=A9rification,=20d=C3=A9tails=20jamais=20d?= =?UTF-8?q?=C3=A9l=C3=A9gu=C3=A9s=20=C3=A0=20wordpress.org?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Unit/UpdaterKeyTest.php | 5 +++++ tests/Unit/UpdaterTest.php | 18 ++++++++++++++++++ tests/wp-stubs.php | 3 +++ wawp-updater/src/class-updater.php | 27 ++++++++++++++++++--------- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/tests/Unit/UpdaterKeyTest.php b/tests/Unit/UpdaterKeyTest.php index 363de89..448981f 100644 --- a/tests/Unit/UpdaterKeyTest.php +++ b/tests/Unit/UpdaterKeyTest.php @@ -42,6 +42,11 @@ public function testInvalidKeyMeansNoRequestAndNotice(mixed $key): void self::assertFalse($updater->filter_update(false, [], 'wawp-demo/wawp-demo.php', [])); self::assertSame([], WawpTestState::$requests); + $info = $updater->filter_plugins_api(false, 'plugin_information', (object) ['slug' => 'wawp-demo']); + self::assertInstanceOf(\WP_Error::class, $info); + self::assertSame('wawp_info_unavailable', $info->get_error_code()); + self::assertSame([], WawpTestState::$requests); + WawpTestState::$screen = (object) ['id' => 'plugins']; ob_start(); $updater->admin_notice(); diff --git a/tests/Unit/UpdaterTest.php b/tests/Unit/UpdaterTest.php index b76f842..dbc5d50 100644 --- a/tests/Unit/UpdaterTest.php +++ b/tests/Unit/UpdaterTest.php @@ -74,6 +74,7 @@ public function testHooksAreRegistered(): void foreach (['update_plugins_updates.wearewp.pro', 'plugins_api', 'upgrader_pre_download', 'upgrader_process_complete', 'load-update-core.php', 'admin_notices', 'admin_init'] as $hook) { self::assertArrayHasKey($hook, WawpTestState::$hooks, $hook); } + self::assertSame([9], WawpTestState::$priorities['load-update-core.php']); } public function testUpdateIsOfferedFromOneCachedRequest(): void @@ -116,10 +117,19 @@ public function testPreDownloadUsesFreshTokenAndVerifiesSignature(): void $updater = $this->updater(); $stale = $updater->filter_update(false, [], self::BASENAME, [])['package']; + $urls = []; + $zip = $this->zip; + WawpTestState::$download = static function (string $url) use ($zip, &$urls): string { + $urls[] = $url; + copy($zip, $zip . '.dl'); + + return $zip . '.dl'; + }; $path = $updater->filter_pre_download(false, $stale, null, ['plugin' => self::BASENAME]); self::assertSame($this->zip . '.dl', $path); self::assertCount(2, WawpTestState::$requests, 'un /v1/check frais au téléchargement'); + self::assertSame(['https://updates.wearewp.pro/v1/download/jeton2'], $urls); } public function testPreDownloadRefusesBadSignatureAndDeletesTheFile(): void @@ -163,6 +173,14 @@ public function testPluginsApiReturnsSanitizedDetails(): void self::assertSame('inchangé', $this->updater()->filter_plugins_api('inchangé', 'query_plugins', $args)); } + public function testPluginsApiNeverDelegatesARegisteredSlugToWordPressOrg(): void + { + WawpTestState::$http = static fn () => new WP_Error('http_request_failed', 'timeout'); + $result = $this->updater()->filter_plugins_api(false, 'plugin_information', (object) ['slug' => 'wawp-demo']); + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('wawp_info_unavailable', $result->get_error_code()); + } + public function testCacheFlushAndLegacyCleanup(): void { $updater = $this->updater(); diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php index 1d58821..bbb6f39 100644 --- a/tests/wp-stubs.php +++ b/tests/wp-stubs.php @@ -10,6 +10,7 @@ final class WawpTestState { /** @var array> */ public static array $hooks = []; + public static array $priorities = []; /** @var array */ public static array $transients = []; /** @var array */ @@ -29,6 +30,7 @@ final class WawpTestState public static function reset(): void { self::$hooks = []; + self::$priorities = []; self::$transients = []; self::$options = []; self::$http = null; @@ -66,6 +68,7 @@ public function get_error_message(): string function add_filter(string $hook, callable $callback, int $priority = 10, int $args = 1): bool { WawpTestState::$hooks[$hook][] = $callback; + WawpTestState::$priorities[$hook][] = $priority; return true; } diff --git a/wawp-updater/src/class-updater.php b/wawp-updater/src/class-updater.php index d6febc3..b80884f 100644 --- a/wawp-updater/src/class-updater.php +++ b/wawp-updater/src/class-updater.php @@ -64,7 +64,7 @@ public function hooks(): void { add_filter( 'plugins_api', array( $this, 'filter_plugins_api' ), 10, 3 ); add_filter( 'upgrader_pre_download', array( $this, 'filter_pre_download' ), 10, 4 ); add_action( 'upgrader_process_complete', array( $this, 'flush_cache' ), 10, 0 ); - add_action( 'load-update-core.php', array( $this, 'maybe_flush_on_force_check' ) ); + add_action( 'load-update-core.php', array( $this, 'maybe_flush_on_force_check' ), 9 ); add_action( 'admin_notices', array( $this, 'admin_notice' ) ); add_action( 'network_admin_notices', array( $this, 'admin_notice' ) ); add_action( 'admin_init', array( $this, 'cleanup_legacy' ) ); @@ -119,13 +119,14 @@ static function ( $value ) { * @return mixed */ public function filter_plugins_api( $result, $action, $args ) { + $unavailable = new \WP_Error( 'wawp_info_unavailable', __( 'Détails de l\'extension WeAre[WP] indisponibles : licence absente ou serveur injoignable.', 'wawp-updater' ) ); try { if ( 'plugin_information' !== $action || ! is_object( $args ) || ! isset( $args->slug ) || ! is_string( $args->slug ) || ! isset( $this->files[ $args->slug ] ) ) { return $result; } $key = $this->key(); if ( null === $key ) { - return $result; + return $unavailable; } $slug = $args->slug; $cache_key = Config::INFO_CACHE_PREFIX . $slug; @@ -134,7 +135,7 @@ public function filter_plugins_api( $result, $action, $args ) { if ( null === $info ) { $info = $this->client->info( $key, $this->site(), $slug ); if ( null === $info ) { - return $result; + return $unavailable; } set_site_transient( $cache_key, (string) wp_json_encode( $info ), Config::CACHE_TTL ); } @@ -155,7 +156,7 @@ public function filter_plugins_api( $result, $action, $args ) { ), ); } catch ( \Throwable $e ) { - return $result; + return is_object( $args ) && isset( $args->slug ) && is_string( $args->slug ) && isset( $this->files[ $args->slug ] ) ? $unavailable : $result; } } @@ -201,9 +202,13 @@ public function filter_pre_download( $reply, $package, $upgrader, $hook_extra ) * Vide les caches du client. */ public function flush_cache(): void { - delete_site_transient( Config::CACHE_KEY ); - foreach ( array_keys( $this->files ) as $slug ) { - delete_site_transient( Config::INFO_CACHE_PREFIX . $slug ); + try { + delete_site_transient( Config::CACHE_KEY ); + foreach ( array_keys( $this->files ) as $slug ) { + delete_site_transient( Config::INFO_CACHE_PREFIX . $slug ); + } + } catch ( \Throwable $e ) { + return; } } @@ -212,8 +217,12 @@ public function flush_cache(): void { */ public function maybe_flush_on_force_check(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- lecture seule : vide uniquement un cache local. - if ( isset( $_GET['force-check'] ) ) { - $this->flush_cache(); + try { + if ( isset( $_GET['force-check'] ) ) { + $this->flush_cache(); + } + } catch ( \Throwable $e ) { + return; } } From 94043bc0cba83d90547ef69c19812cbdc7c57b1b Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 13:12:22 +0200 Subject: [PATCH 06/23] =?UTF-8?q?wawp-updater=20:=20chargement=20partag?= =?UTF-8?q?=C3=A9,=20la=20copie=20la=20plus=20r=C3=A9cente=20g=C3=A8re=20t?= =?UTF-8?q?ous=20les=20plugins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute load.php et bootstrap.php, le test du chargeur, et corrige les constats PHPCS et PHPStan de la bibliothèque sans changer son comportement. --- tests/Unit/LoaderTest.php | 68 +++++++++++++++++++++ wawp-updater/bootstrap.php | 26 ++++++++ wawp-updater/load.php | 89 ++++++++++++++++++++++++++++ wawp-updater/src/class-updater.php | 7 ++- wawp-updater/src/class-validator.php | 7 ++- 5 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/LoaderTest.php create mode 100644 wawp-updater/bootstrap.php create mode 100644 wawp-updater/load.php diff --git a/tests/Unit/LoaderTest.php b/tests/Unit/LoaderTest.php new file mode 100644 index 0000000..bba7182 --- /dev/null +++ b/tests/Unit/LoaderTest.php @@ -0,0 +1,68 @@ +dir = sys_get_temp_dir() . '/wawp-loader-' . bin2hex(random_bytes(4)); + $source = dirname(__DIR__, 2) . '/wawp-updater'; + foreach (['ancien' => '1.0.0', 'recent' => '1.2.0'] as $name => $version) { + mkdir("{$this->dir}/$name", 0777, true); + $load = (string) file_get_contents("$source/load.php"); + file_put_contents("{$this->dir}/$name/load.php", str_replace("\$wawp_updater_version = '1.0.0';", "\$wawp_updater_version = '$version';", $load)); + file_put_contents("{$this->dir}/$name/bootstrap.php", "dir}/$name/load.php"); + @unlink("{$this->dir}/$name/bootstrap.php"); + @rmdir("{$this->dir}/$name"); + } + @rmdir($this->dir); + } + + private function load(array $order): string + { + $code = 'define("ABSPATH", "/");' + . '$GLOBALS["h"] = [];' + . 'function add_action($n, $c, $p = 10) { $GLOBALS["h"][$n][] = $c; }'; + foreach ($order as $i => $name) { + $code .= 'require ' . var_export("{$this->dir}/$name/load.php", true) . ';' + . '\WAWP\Updater\register("/plugins/p' . $i . '/p' . $i . '.php");'; + } + $code .= 'foreach ($GLOBALS["h"]["plugins_loaded"] as $c) { $c(); }' + . 'echo "|" . count($GLOBALS["h"]["plugins_loaded"]);'; + $process = proc_open([PHP_BINARY, '-r', $code], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + self::assertIsResource($process); + $out = (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + + return $out; + } + + public function testHighestVersionWinsWhateverTheOrder(): void + { + self::assertSame('1.2.0:/plugins/p0/p0.php,/plugins/p1/p1.php|1', $this->load(['ancien', 'recent'])); + self::assertSame('1.2.0:/plugins/p0/p0.php,/plugins/p1/p1.php|1', $this->load(['recent', 'ancien'])); + } + + public function testVersionConstantMatchesLoader(): void + { + $load = (string) file_get_contents(dirname(__DIR__, 2) . '/wawp-updater/load.php'); + self::assertSame(1, preg_match('/\$wawp_updater_version = \'([0-9.]+)\';/', $load, $m)); + self::assertSame(\WAWP\Updater\Config::VERSION, $m[1]); + } +} diff --git a/wawp-updater/bootstrap.php b/wawp-updater/bootstrap.php new file mode 100644 index 0000000..006eb3e --- /dev/null +++ b/wawp-updater/bootstrap.php @@ -0,0 +1,26 @@ +hooks(); +}; diff --git a/wawp-updater/load.php b/wawp-updater/load.php new file mode 100644 index 0000000..6cd5ef8 --- /dev/null +++ b/wawp-updater/load.php @@ -0,0 +1,89 @@ + array(), + 'plugins' => array(), + 'booted' => false, + ); + add_action( 'plugins_loaded', __NAMESPACE__ . '\boot', 0 ); +} + +$wawp_updater_copies = isset( $wawp_updater_registry['copies'] ) && is_array( $wawp_updater_registry['copies'] ) ? $wawp_updater_registry['copies'] : array(); +if ( ! isset( $wawp_updater_copies[ $wawp_updater_version ] ) ) { + $wawp_updater_copies[ $wawp_updater_version ] = __DIR__; +} +$wawp_updater_registry['copies'] = $wawp_updater_copies; +$GLOBALS['wawp_updater_registry'] = $wawp_updater_registry; + +if ( ! function_exists( __NAMESPACE__ . '\register' ) ) { + + /** + * Inscrit un plugin auprès du client de mise à jour. Signature figée. + * + * @param string $plugin_file Fichier principal du plugin (__FILE__). + * @return void + */ + function register( $plugin_file ) { + $registry = $GLOBALS['wawp_updater_registry'] ?? null; + if ( ! is_array( $registry ) ) { + return; + } + $plugins = isset( $registry['plugins'] ) && is_array( $registry['plugins'] ) ? $registry['plugins'] : array(); + + $plugins[ (string) $plugin_file ] = true; + $registry['plugins'] = $plugins; + $GLOBALS['wawp_updater_registry'] = $registry; + } + + /** + * Charge la copie la plus récente et démarre le client. Comportement figé. + * + * @return void + */ + function boot() { + try { + $registry = $GLOBALS['wawp_updater_registry'] ?? null; + if ( ! is_array( $registry ) || ! empty( $registry['booted'] ) ) { + return; + } + $copies = isset( $registry['copies'] ) && is_array( $registry['copies'] ) ? $registry['copies'] : array(); + $plugins = isset( $registry['plugins'] ) && is_array( $registry['plugins'] ) ? $registry['plugins'] : array(); + if ( array() === $copies || array() === $plugins ) { + return; + } + $registry['booted'] = true; + $GLOBALS['wawp_updater_registry'] = $registry; + + $versions = array_map( 'strval', array_keys( $copies ) ); + usort( $versions, 'version_compare' ); + $latest = end( $versions ); + if ( ! isset( $copies[ $latest ] ) || ! is_string( $copies[ $latest ] ) ) { + return; + } + + $start = require $copies[ $latest ] . '/bootstrap.php'; + if ( is_callable( $start ) ) { + $start( array_map( 'strval', array_keys( $plugins ) ) ); + } + } catch ( \Throwable $e ) { + return; + } + } +} diff --git a/wawp-updater/src/class-updater.php b/wawp-updater/src/class-updater.php index b80884f..2d1ed5f 100644 --- a/wawp-updater/src/class-updater.php +++ b/wawp-updater/src/class-updater.php @@ -79,6 +79,7 @@ public function hooks(): void { * @param mixed $locales Langues installées. * @return mixed */ + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- signature imposée par le filtre update_plugins_{hôte} (4 arguments). public function filter_update( $update, $plugin_data, $plugin_file, $locales ) { try { $slug = is_string( $plugin_file ) ? ( $this->basenames[ $plugin_file ] ?? null ) : null; @@ -184,7 +185,7 @@ public function filter_pre_download( $reply, $package, $upgrader, $hook_extra ) return new \WP_Error( 'wawp_token_unavailable', __( 'Mise à jour WeAre[WP] indisponible : licence refusée ou serveur injoignable.', 'wawp-updater' ) ); } $file = download_url( $offers[ $slug ]['package'], Config::DOWNLOAD_TIMEOUT ); - if ( is_wp_error( $file ) || ! is_string( $file ) ) { + if ( is_wp_error( $file ) ) { return new \WP_Error( 'wawp_download_failed', __( 'Téléchargement du paquet WeAre[WP] impossible.', 'wawp-updater' ) ); } if ( ! Signature::verify_file( $file, $offers[ $slug ]['signature'], $this->public_keys ) ) { @@ -216,8 +217,8 @@ public function flush_cache(): void { * Vide le cache quand l'administrateur clique « Rechercher les mises à jour ». */ public function maybe_flush_on_force_check(): void { - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- lecture seule : vide uniquement un cache local. try { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- lecture seule : isset() sans lire la valeur, vide uniquement un cache local. if ( isset( $_GET['force-check'] ) ) { $this->flush_cache(); } @@ -235,7 +236,7 @@ public function admin_notice(): void { return; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; - if ( null === $screen || ! isset( $screen->id ) || ! in_array( $screen->id, array( 'plugins', 'plugins-network' ), true ) ) { + if ( null === $screen || ! in_array( $screen->id, array( 'plugins', 'plugins-network' ), true ) ) { return; } printf( diff --git a/wawp-updater/src/class-validator.php b/wawp-updater/src/class-validator.php index 3fbd228..1f37896 100644 --- a/wawp-updater/src/class-validator.php +++ b/wawp-updater/src/class-validator.php @@ -26,6 +26,7 @@ final class Validator { /** * @param mixed $value Valeur à tester. + * @phpstan-assert-if-true string $value */ public static function is_key( $value ): bool { return self::matches( self::KEY, $value ); @@ -159,17 +160,19 @@ private static function decode( $body ): ?array { /** * @param mixed $value Valeur à tester. + * @phpstan-assert-if-true string $value */ private static function is_package_url( $value ): bool { $prefix = Config::ENDPOINT . '/v1/download/'; return is_string( $value ) && 0 === strpos( $value, $prefix ) - && self::matches( self::TOKEN, (string) substr( $value, strlen( $prefix ) ) ); + && 1 === preg_match( self::TOKEN, (string) substr( $value, strlen( $prefix ) ) ); } /** * @param mixed $value Valeur à tester. + * @phpstan-assert-if-true string $value */ private static function is_signature( $value ): bool { if ( ! is_string( $value ) ) { @@ -183,6 +186,7 @@ private static function is_signature( $value ): bool { /** * @param string $pattern Motif. * @param mixed $value Valeur à tester. + * @phpstan-assert-if-true string|null $value */ private static function optional( string $pattern, $value ): bool { return null === $value || self::matches( $pattern, $value ); @@ -191,6 +195,7 @@ private static function optional( string $pattern, $value ): bool { /** * @param string $pattern Motif. * @param mixed $value Valeur à tester. + * @phpstan-assert-if-true string $value */ private static function matches( string $pattern, $value ): bool { return is_string( $value ) && 1 === preg_match( $pattern, $value ); From 4100befa80837437e95a9d8e2b54ce4915cdfd21 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 13:15:12 +0200 Subject: [PATCH 07/23] =?UTF-8?q?G=C3=A9n=C3=A9ration=20des=20cl=C3=A9s=20?= =?UTF-8?q?et=20signature=20des=20paquets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/keygen.php | 40 ++++++++++++++++++++++++ scripts/sign.php | 49 ++++++++++++++++++++++++++++++ tests/Support/Cli.php | 33 ++++++++++++++++++++ tests/Unit/KeygenTest.php | 29 ++++++++++++++++++ tests/Unit/SignScriptTest.php | 57 +++++++++++++++++++++++++++++++++++ 5 files changed, 208 insertions(+) create mode 100644 bin/keygen.php create mode 100644 scripts/sign.php create mode 100644 tests/Support/Cli.php create mode 100644 tests/Unit/KeygenTest.php create mode 100644 tests/Unit/SignScriptTest.php diff --git a/bin/keygen.php b/bin/keygen.php new file mode 100644 index 0000000..e0d456f --- /dev/null +++ b/bin/keygen.php @@ -0,0 +1,40 @@ + base64_encode(sodium_crypto_sign_secretkey($keypair)), + 'public' => base64_encode(sodium_crypto_sign_publickey($keypair)), + ]; + sodium_memzero($keypair); +} + +$active = $pairs['ACTIVE']; +$backup = $pairs['SECOURS']; + +echo "=== Clé ACTIVE ===\n"; +echo "Privée (secret d'organisation GitHub SIGNING_KEY, et copie dans le gestionnaire de mots de passe) :\n"; +echo $active['secret'] . "\n"; +echo "Publique :\n"; +echo $active['public'] . "\n\n"; +echo "=== Clé de SECOURS ===\n"; +echo "Privée (gestionnaire de mots de passe UNIQUEMENT, jamais sur GitHub ni sur un serveur) :\n"; +echo $backup['secret'] . "\n"; +echo "Publique :\n"; +echo $backup['public'] . "\n\n"; +echo "=== Contenu de ~/updates-data/signing.pub sur o2switch ===\n"; +echo "# active\n{$active['public']}\n# secours\n{$backup['public']}\n\n"; +echo "=== Ligne à placer dans wawp-updater/src/class-config.php ===\n"; +echo "const PUBLIC_KEYS = array( '{$active['public']}', '{$backup['public']}' );\n\n"; +echo "Rien n'a été écrit sur disque. Effacez ce terminal après avoir copié les valeurs.\n"; diff --git a/scripts/sign.php b/scripts/sign.php new file mode 100644 index 0000000..ec46358 --- /dev/null +++ b/scripts/sign.php @@ -0,0 +1,49 @@ +.sig. + * La clé privée (64 octets en base64) est lue dans la variable d'environnement SIGNING_KEY et n'est jamais affichée. + */ + +declare(strict_types=1); + +if (PHP_SAPI !== 'cli') { + exit(1); +} + +$fail = static function (string $message): void { + fwrite(STDERR, $message . "\n"); + exit(1); +}; + +$zip = $argv[1] ?? ''; +if ($zip === '' || !is_file($zip)) { + $fail('Paquet introuvable.'); +} + +$encoded = getenv('SIGNING_KEY'); +if (!is_string($encoded) || trim($encoded) === '') { + $fail('Variable SIGNING_KEY absente.'); +} +$secret = base64_decode(trim((string) $encoded), true); +if ($secret === false || strlen($secret) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) { + $fail('SIGNING_KEY invalide (64 octets en base64 attendus).'); +} + +$digest = hash_file('sha384', $zip, true); +if ($digest === false) { + $fail('Lecture du paquet impossible.'); +} + +$signature = sodium_crypto_sign_detached((string) $digest, (string) $secret); +$public = sodium_crypto_sign_publickey_from_secretkey((string) $secret); +sodium_memzero($secret); + +if (!sodium_crypto_sign_verify_detached($signature, (string) $digest, $public)) { + $fail('Auto-vérification de la signature en échec.'); +} +if (file_put_contents($zip . '.sig', base64_encode($signature) . "\n") === false) { + $fail('Écriture de la signature impossible.'); +} + +echo 'Signature écrite : ' . basename($zip) . ".sig\n"; +echo 'Clé publique utilisée : ' . base64_encode($public) . "\n"; diff --git a/tests/Support/Cli.php b/tests/Support/Cli.php new file mode 100644 index 0000000..a65a02a --- /dev/null +++ b/tests/Support/Cli.php @@ -0,0 +1,33 @@ + $command + * @param array $env + * @return array{int, string, string} + */ + public static function run(array $command, array $env = []): array + { + $environment = array_merge(getenv(), $env); + $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, $environment); + if (!is_resource($process)) { + throw new \RuntimeException('Lancement impossible'); + } + $out = (string) stream_get_contents($pipes[1]); + $err = (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return [proc_close($process), $out, $err]; + } + + public static function php(string $script): string + { + return dirname(__DIR__, 2) . '/' . $script; + } +} diff --git a/tests/Unit/KeygenTest.php b/tests/Unit/KeygenTest.php new file mode 100644 index 0000000..bc35c7b --- /dev/null +++ b/tests/Unit/KeygenTest.php @@ -0,0 +1,29 @@ +zip = sys_get_temp_dir() . '/wawp-sign-' . bin2hex(random_bytes(4)) . '.zip'; + file_put_contents($this->zip, random_bytes(4096)); + } + + protected function tearDown(): void + { + foreach ([$this->zip, $this->zip . '.sig'] as $file) { + if (is_file($file)) { + unlink($file); + } + } + } + + public function testSignatureVerifiesWithTheClientAndKeyIsNeverPrinted(): void + { + $pair = Keys::pair(); + [$code, $out, $err] = Cli::run([PHP_BINARY, Cli::php('scripts/sign.php'), $this->zip], ['SIGNING_KEY' => $pair['secret']]); + + self::assertSame(0, $code, $err); + self::assertFileExists($this->zip . '.sig'); + self::assertTrue(Signature::verify_file($this->zip, (string) file_get_contents($this->zip . '.sig'), [$pair['public']])); + self::assertStringContainsString($pair['public'], $out); + self::assertStringNotContainsString($pair['secret'], $out . $err); + } + + public function testInvalidInputsFailWithoutWritingASignature(): void + { + $cases = [ + 'clé absente' => [$this->zip, []], + 'clé tronquée' => [$this->zip, ['SIGNING_KEY' => base64_encode('court')]], + 'clé non base64' => [$this->zip, ['SIGNING_KEY' => '***']], + 'paquet absent' => [$this->zip . '.absent', ['SIGNING_KEY' => Keys::pair()['secret']]], + ]; + foreach ($cases as $label => [$zip, $env]) { + [$code] = Cli::run([PHP_BINARY, Cli::php('scripts/sign.php'), $zip], $env + ['SIGNING_KEY' => '']); + self::assertSame(1, $code, $label); + self::assertFileDoesNotExist($this->zip . '.sig', $label); + } + } +} From a2ce378e24a21de01480f3b4e2d8804681037c8d Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 13:17:19 +0200 Subject: [PATCH 08/23] =?UTF-8?q?Contr=C3=B4les=20d'un=20plugin=20avant=20?= =?UTF-8?q?publication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/min-updater-version.txt | 1 + scripts/check-headers.php | 108 ++++++++++++++++++++++++++++++++ tests/Unit/CheckHeadersTest.php | 80 +++++++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 config/min-updater-version.txt create mode 100644 scripts/check-headers.php create mode 100644 tests/Unit/CheckHeadersTest.php diff --git a/config/min-updater-version.txt b/config/min-updater-version.txt new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/config/min-updater-version.txt @@ -0,0 +1 @@ +1.0.0 diff --git a/scripts/check-headers.php b/scripts/check-headers.php new file mode 100644 index 0000000..11eb996 --- /dev/null +++ b/scripts/check-headers.php @@ -0,0 +1,108 @@ + + */ + +declare(strict_types=1); + +if (PHP_SAPI !== 'cli') { + exit(1); +} + +$dir = rtrim($argv[1] ?? '', '/\\'); +$slug = $argv[2] ?? ''; +$tag = $argv[3] ?? ''; +$minUpdater = trim($argv[4] ?? ''); +$errors = []; +$version = ''; + +$read = static function (string $path, int $limit = 0): ?string { + if (!is_file($path)) { + return null; + } + $content = $limit > 0 ? file_get_contents($path, false, null, 0, $limit) : file_get_contents($path); + + return $content === false ? null : str_replace("\r", "\n", $content); +}; +$header = static function (string $content, string $name): ?string { + $pattern = '/^(?:[ \t]*<\?php)?[ \t\/*#@]*' . preg_quote($name, '/') . ':(.*)$/mi'; + if (preg_match($pattern, $content, $m) !== 1) { + return null; + } + $value = trim((string) preg_replace('/\s*(?:\*\/|\?>).*/', '', $m[1])); + + return $value === '' ? null : $value; +}; + +if (preg_match('/^[a-z0-9-]{1,64}\z/', $slug) !== 1) { + $errors[] = "Slug invalide : « $slug » (le nom du dépôt doit être le slug du plugin, en minuscules)."; +} +if (preg_match('/^v(\d{1,5}\.\d{1,5}\.\d{1,5})\z/', $tag, $m) === 1) { + $version = $m[1]; +} else { + $errors[] = "Tag invalide : « $tag » (format attendu vX.Y.Z)."; +} + +$main = $read("$dir/$slug.php", 8192); +if ($main === null) { + $errors[] = "Fichier principal $slug.php absent."; +} else { + $name = $header($main, 'Plugin Name'); + if ($name === null || preg_match('/^[^\p{Cc}]{1,200}\z/u', $name) !== 1) { + $errors[] = 'En-tête Plugin Name absent ou invalide (UTF-8, 200 caractères au plus, sans caractère de contrôle).'; + } + $declared = $header($main, 'Version'); + if ($version !== '' && $declared !== $version) { + $errors[] = 'En-tête Version (' . ($declared ?? 'absent') . ") différent du tag ($version)."; + } + if ($header($main, 'Update URI') !== 'https://updates.wearewp.pro') { + $errors[] = 'En-tête Update URI absent ou différent de https://updates.wearewp.pro.'; + } + if ($header($main, 'GitHub Plugin URI') !== null) { + $errors[] = 'En-tête GitHub Plugin URI présent : retirer les en-têtes Git Updater.'; + } +} + +$readme = $read("$dir/readme.txt"); +if ($readme === null) { + $errors[] = 'readme.txt absent.'; +} elseif (preg_match('/^Stable tag:[ \t]*(\S+)[ \t]*$/mi', $readme, $m) !== 1 || ($version !== '' && $m[1] !== $version)) { + $errors[] = "Stable tag du readme.txt absent ou différent de $version."; +} + +if (!is_file("$dir/.distignore")) { + $errors[] = 'Fichier .distignore absent.'; +} +if (is_dir("$dir/plugin-update-checker") || is_dir("$dir/vendor/yahnis-elsts/plugin-update-checker")) { + $errors[] = 'Plugin Update Checker encore embarqué : le retirer.'; +} + +$load = $read("$dir/wawp-updater/load.php"); +if ($load === null || preg_match("/\\\$wawp_updater_version = '(\d+\.\d+\.\d+)';/", $load, $m) !== 1) { + $errors[] = 'wawp-updater absent ou version illisible (wawp-updater/load.php).'; +} elseif (version_compare($m[1], $minUpdater, '<')) { + $errors[] = "wawp-updater {$m[1]} trop ancien (minimum $minUpdater)."; +} + +$config = $read("$dir/wawp-updater/src/class-config.php"); +$keys = []; +if ($config !== null && preg_match('/const PUBLIC_KEYS = array\(([^)]*)\);/', $config, $m) === 1) { + preg_match_all("/'([A-Za-z0-9+\\/=]+)'/", $m[1], $found); + $keys = $found[1]; +} +$validKeys = array_filter($keys, static function (string $key): bool { + $raw = base64_decode($key, true); + + return $raw !== false && strlen($raw) === 32; +}); +if (count($validKeys) !== 2 || count(array_unique($validKeys)) !== 2) { + $errors[] = 'PUBLIC_KEYS doit contenir exactement deux clés publiques Ed25519 distinctes (wawp-updater/src/class-config.php).'; +} + +if ($errors !== []) { + fwrite(STDERR, "Contrôles en échec :\n- " . implode("\n- ", $errors) . "\n"); + exit(1); +} + +echo "Contrôles réussis : $slug $version\n"; diff --git a/tests/Unit/CheckHeadersTest.php b/tests/Unit/CheckHeadersTest.php new file mode 100644 index 0000000..a5c1a91 --- /dev/null +++ b/tests/Unit/CheckHeadersTest.php @@ -0,0 +1,80 @@ +dir = sys_get_temp_dir() . '/wawp-check-' . bin2hex(random_bytes(4)) . '/wawp-demo'; + mkdir($this->dir . '/wawp-updater/src', 0777, true); + $keys = [Keys::pair()['public'], Keys::pair()['public']]; + $this->write('wawp-demo.php', "write('readme.txt', "=== WAWP Démo ===\nStable tag: 1.4.0\n"); + $this->write('.distignore', "/.git\n/tests\n"); + $this->write('wawp-updater/load.php', "write('wawp-updater/src/class-config.php', "dir); + $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($it as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($root); + } + + private function write(string $path, string $content): void + { + file_put_contents($this->dir . '/' . $path, $content); + } + + /** @return array{int, string} */ + private function check(string $slug = 'wawp-demo', string $tag = 'v1.4.0', string $min = '1.0.0'): array + { + [$code, $out, $err] = Cli::run([PHP_BINARY, Cli::php('scripts/check-headers.php'), $this->dir, $slug, $tag, $min]); + + return [$code, $out . $err]; + } + + public function testValidPluginPasses(): void + { + [$code, $out] = $this->check(); + self::assertSame(0, $code, $out); + self::assertStringContainsString('Contrôles réussis : wawp-demo 1.4.0', $out); + } + + public function testEachDefectIsReported(): void + { + $cases = [ + 'tag' => [fn () => null, ['wawp-demo', 'v1.4', '1.0.0'], 'Tag invalide'], + 'slug' => [fn () => null, ['Wawp_Demo', 'v1.4.0', '1.0.0'], 'Slug invalide'], + 'version' => [fn () => $this->write('wawp-demo.php', " [fn () => $this->write('wawp-demo.php', " [fn () => $this->write('readme.txt', "Stable tag: 1.4.1\n"), null, 'Stable tag'], + 'distignore' => [fn () => unlink($this->dir . '/.distignore'), null, '.distignore'], + 'updater ancien' => [fn () => null, ['wawp-demo', 'v1.4.0', '1.1.0'], 'wawp-updater'], + 'clés vides' => [fn () => $this->write('wawp-updater/src/class-config.php', " [fn () => mkdir($this->dir . '/plugin-update-checker'), null, 'Plugin Update Checker'], + 'git updater' => [fn () => $this->write('wawp-demo.php', " [$mutate, $args, $expected]) { + $this->tearDown(); + $this->setUp(); + $mutate(); + [$code, $out] = $args === null ? $this->check() : $this->check(...$args); + self::assertSame(1, $code, $label); + self::assertStringContainsString($expected, $out, $label); + } + } +} From f324a68d88053b69f65127975399ed3b854229b9 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 13:20:58 +0200 Subject: [PATCH 09/23] =?UTF-8?q?Workflow=20r=C3=A9utilisable=20de=20publi?= =?UTF-8?q?cation=20:=20construction,=20signature=20et=20envoi=20FTPS,=20a?= =?UTF-8?q?rchive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/plugin-release.yml | 159 +++++++++++++++++++++++++++ scripts/upload-ftps.sh | 25 +++++ templates/release.yml | 14 +++ tests/Unit/WorkflowPolicyTest.php | 92 ++++++++++++++++ 4 files changed, 290 insertions(+) create mode 100644 .github/workflows/plugin-release.yml create mode 100644 scripts/upload-ftps.sh create mode 100644 templates/release.yml create mode 100644 tests/Unit/WorkflowPolicyTest.php diff --git a/.github/workflows/plugin-release.yml b/.github/workflows/plugin-release.yml new file mode 100644 index 0000000..31b4de1 --- /dev/null +++ b/.github/workflows/plugin-release.yml @@ -0,0 +1,159 @@ +name: Publication d'un plugin + +on: + workflow_call: + inputs: + tools-ref: + description: Version de release-tools utilisée pour les scripts (doit correspondre à la version du workflow). + type: string + default: v1 + +permissions: + contents: read + +jobs: + build: + name: Contrôles et paquet + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + outputs: + slug: ${{ steps.meta.outputs.slug }} + version: ${{ steps.meta.outputs.version }} + steps: + - name: Code du plugin + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + path: plugin + persist-credentials: false + + - name: Outils de publication + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: wearewp/release-tools + ref: ${{ inputs.tools-ref }} + path: tools + persist-credentials: false + + - name: Slug et version + id: meta + run: | + echo "slug=${GITHUB_REPOSITORY#*/}" >> "$GITHUB_OUTPUT" + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Contrôles avant publication + env: + SLUG: ${{ steps.meta.outputs.slug }} + run: php tools/scripts/check-headers.php plugin "$SLUG" "$GITHUB_REF_NAME" "$(cat tools/config/min-updater-version.txt)" + + - name: Syntaxe PHP + run: find plugin -name '*.php' -not -path '*/node_modules/*' -print0 | xargs -0 -n1 php -l > /dev/null + + - name: Dépendances Composer + if: hashFiles('plugin/composer.json') != '' + working-directory: plugin + run: composer install --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader + + - name: Assets + if: hashFiles('plugin/package.json') != '' + working-directory: plugin + run: npm ci && npm run build + + - name: Paquet + env: + SLUG: ${{ steps.meta.outputs.slug }} + VERSION: ${{ steps.meta.outputs.version }} + run: | + mkdir -p "build/$SLUG" dist + rsync -a --exclude-from=plugin/.distignore --exclude=.git --exclude=.github plugin/ "build/$SLUG/" + (cd build && zip -rqX "../dist/$SLUG-$VERSION.zip" "$SLUG") + + - name: Conservation du paquet + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: paquet + path: dist/ + retention-days: 1 + if-no-files-found: error + + sign-upload: + name: Signature et envoi + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + concurrency: + group: release-upload-${{ github.repository }} + cancel-in-progress: false + steps: + - name: Outils de publication + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: wearewp/release-tools + ref: ${{ inputs.tools-ref }} + path: tools + persist-credentials: false + + - name: Récupération du paquet + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: paquet + path: dist + + - name: Signature + env: + SIGNING_KEY: ${{ secrets.SIGNING_KEY }} + ZIP: dist/${{ needs.build.outputs.slug }}-${{ needs.build.outputs.version }}.zip + run: php tools/scripts/sign.php "$ZIP" + + - name: Envoi FTPS vers le serveur de mises à jour + env: + FTP_HOST: ${{ secrets.FTP_HOST }} + FTP_USER: ${{ secrets.FTP_USER }} + LFTP_PASSWORD: ${{ secrets.FTP_PASSWORD }} + ZIP: dist/${{ needs.build.outputs.slug }}-${{ needs.build.outputs.version }}.zip + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq lftp > /dev/null + bash tools/scripts/upload-ftps.sh "$ZIP" + + - name: Conservation de la signature + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: signature + path: dist/*.sig + retention-days: 1 + if-no-files-found: error + + publish-release: + name: Archive dans la Release GitHub + needs: [build, sign-upload] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + steps: + - name: Récupération du paquet + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: paquet + path: dist + + - name: Récupération de la signature + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: signature + path: dist + + - name: Release + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --clobber + else + gh release create "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --title "$GITHUB_REF_NAME" --notes "Paquet signé publié sur updates.wearewp.pro." + fi diff --git a/scripts/upload-ftps.sh b/scripts/upload-ftps.sh new file mode 100644 index 0000000..7ee5984 --- /dev/null +++ b/scripts/upload-ftps.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Envoie un paquet et sa signature en FTPS : chaque fichier en .part puis renommé, la signature en dernier. +# Variables : FTP_HOST, FTP_USER, LFTP_PASSWORD. Le mot de passe ne passe jamais sur la ligne de commande. +set -euo pipefail + +zip="${1:-}" +sig="$zip.sig" +for variable in FTP_HOST FTP_USER LFTP_PASSWORD; do + if [ -z "${!variable:-}" ]; then + echo "Variable manquante : $variable" >&2 + exit 1 + fi +done +if [ ! -f "$zip" ] || [ ! -f "$sig" ]; then + echo "Paquet ou signature absent : $zip" >&2 + exit 1 +fi + +name="$(basename "$zip")" +lftp -c "set ftp:ssl-force true; set ftp:ssl-protect-data true; set ssl:verify-certificate true; set net:max-retries 2; set net:timeout 30; \ +open --env-password -u \"$FTP_USER\" \"ftp://$FTP_HOST\"; \ +put \"$zip\" -o \"$name.part\"; mv \"$name.part\" \"$name\"; \ +put \"$sig\" -o \"$name.sig.part\"; mv \"$name.sig.part\" \"$name.sig\"" + +echo "Envoyé : $name et $name.sig" diff --git a/templates/release.yml b/templates/release.yml new file mode 100644 index 0000000..f739bb1 --- /dev/null +++ b/templates/release.yml @@ -0,0 +1,14 @@ +name: Publication + +on: + push: + tags: ['v*.*.*'] + +permissions: {} + +jobs: + release: + permissions: + contents: write + uses: wearewp/release-tools/.github/workflows/plugin-release.yml@v1 + secrets: inherit diff --git a/tests/Unit/WorkflowPolicyTest.php b/tests/Unit/WorkflowPolicyTest.php new file mode 100644 index 0000000..f4b7fd9 --- /dev/null +++ b/tests/Unit/WorkflowPolicyTest.php @@ -0,0 +1,92 @@ + blocs de jobs indexés par nom */ + private static function jobs(string $yaml): array + { + $parts = preg_split('/^ ([a-z-]+):\n/m', (string) substr($yaml, (int) strpos($yaml, "\njobs:\n")), -1, PREG_SPLIT_DELIM_CAPTURE) ?: []; + $jobs = []; + for ($i = 1; $i + 1 < count($parts); $i += 2) { + $jobs[$parts[$i]] = $parts[$i + 1]; + } + + return $jobs; + } + + public function testEveryActionIsOfficialAndPinnedBySha(): void + { + foreach (['.github/workflows/plugin-release.yml', 'templates/release.yml'] as $path) { + preg_match_all('/^\s*(?:-\s*)?uses:\s*(\S+)/m', self::file($path), $m); + foreach ($m[1] as $uses) { + if (str_starts_with($uses, 'wearewp/release-tools/')) { + continue; + } + self::assertMatchesRegularExpression('#^actions/[a-z-]+@[0-9a-f]{40}$#', $uses, "$path : $uses"); + } + } + } + + public function testJobSeparationAndPermissions(): void + { + $yaml = self::file('.github/workflows/plugin-release.yml'); + self::assertStringContainsString("permissions:\n contents: read\n", $yaml); + $jobs = self::jobs($yaml); + self::assertSame(['build', 'sign-upload', 'publish-release'], array_keys($jobs)); + + self::assertStringNotContainsString('secrets.', $jobs['build'], 'aucun secret dans la construction'); + self::assertStringContainsString("if: startsWith(github.ref, 'refs/tags/v')", $jobs['build']); + + self::assertStringContainsString('secrets.SIGNING_KEY', $jobs['sign-upload']); + self::assertStringNotContainsString('path: plugin', $jobs['sign-upload'], 'aucun code du plugin dans le job à secrets'); + self::assertStringNotContainsString('composer', $jobs['sign-upload']); + self::assertStringNotContainsString('npm', $jobs['sign-upload']); + self::assertStringContainsString("contents: read", $jobs['sign-upload']); + + self::assertStringContainsString("contents: write", $jobs['publish-release']); + self::assertStringNotContainsString('secrets.SIGNING_KEY', $jobs['publish-release']); + self::assertStringNotContainsString('secrets.FTP', $jobs['publish-release']); + + foreach ($jobs as $name => $job) { + self::assertStringContainsString('timeout-minutes:', $job, $name); + self::assertSame( + substr_count($job, 'actions/checkout@'), + substr_count($job, 'persist-credentials: false'), + "$name : chaque checkout doit désactiver la conservation des identifiants" + ); + } + self::assertStringNotContainsString('actions/checkout@', $jobs['publish-release'], 'aucun clone dans le job qui écrit'); + } + + public function testCallerTemplate(): void + { + $yaml = self::file('templates/release.yml'); + self::assertStringContainsString("tags: ['v*.*.*']", $yaml); + self::assertStringContainsString('permissions: {}', $yaml); + self::assertStringContainsString('uses: wearewp/release-tools/.github/workflows/plugin-release.yml@v1', $yaml); + self::assertStringContainsString('secrets: inherit', $yaml); + } + + public function testUploadScriptForcesTlsAndUsesPartFiles(): void + { + $script = self::file('scripts/upload-ftps.sh'); + foreach (['set ftp:ssl-force true', 'set ftp:ssl-protect-data true', 'set ssl:verify-certificate true', '--env-password', '.part'] as $needle) { + self::assertStringContainsString($needle, $script); + } + self::assertLessThan((int) strpos($script, '$name.sig.part'), (int) strpos($script, '"$name.part"'), 'zip avant signature'); + [$code, , $err] = Cli::run(['bash', '-n', 'scripts/upload-ftps.sh']); + self::assertSame(0, $code, $err); + } +} From c3a3e99037365a5ab71d769f36c84d6df9ee2881 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 13:24:56 +0200 Subject: [PATCH 10/23] =?UTF-8?q?Script=20de=20pr=C3=A9paration=20d'un=20d?= =?UTF-8?q?=C3=A9p=C3=B4t=20de=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/setup-repo.sh | 68 ++++++++++++++++++++++++++++++++++++ tests/Unit/SetupRepoTest.php | 50 ++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 bin/setup-repo.sh create mode 100644 tests/Unit/SetupRepoTest.php diff --git a/bin/setup-repo.sh b/bin/setup-repo.sh new file mode 100644 index 0000000..f3a34b0 --- /dev/null +++ b/bin/setup-repo.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Prépare un dépôt de plugin pour la publication automatique. Ne manipule jamais la valeur d'un secret. +# Usage : bash bin/setup-repo.sh wearewp/ +set -euo pipefail + +repo="${1:-}" +path="${2:-}" +tools="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ ! "$repo" =~ ^wearewp/[a-z0-9-]{1,64}$ ]]; then + echo "Dépôt attendu sous la forme wearewp/ (slug en minuscules)." >&2 + exit 1 +fi +if [ -z "$path" ] || [ ! -d "$path" ]; then + echo "Chemin local du plugin introuvable : $path" >&2 + exit 1 +fi +slug="${repo#wearewp/}" + +visibility="$(gh api "repos/$repo" --jq '.visibility')" +if [ "$visibility" != "private" ]; then + echo "Le dépôt $repo doit être privé (visibilité actuelle : $visibility)." >&2 + exit 1 +fi +repo_id="$(gh api "repos/$repo" --jq '.id')" + +for secret in SIGNING_KEY FTP_HOST FTP_USER FTP_PASSWORD; do + gh api -X PUT "orgs/wearewp/actions/secrets/$secret/repositories/$repo_id" > /dev/null + echo "Secret $secret accessible à $repo." +done + +if gh api "repos/$repo/rulesets" --jq '.[].name' | grep -qx 'Tags de version immuables'; then + echo "Ruleset déjà présent." +else + gh api -X POST "repos/$repo/rulesets" --input - > /dev/null <<'JSON' +{ + "name": "Tags de version immuables", + "target": "tag", + "enforcement": "active", + "conditions": { "ref_name": { "include": ["refs/tags/v*"], "exclude": [] } }, + "rules": [ { "type": "deletion" }, { "type": "update" }, { "type": "non_fast_forward" } ] +} +JSON + echo "Ruleset « Tags de version immuables » créé." +fi + +mkdir -p "$path/.github/workflows" +if [ -f "$path/.github/workflows/release.yml" ]; then + echo "release.yml déjà présent : non modifié." +else + cp "$tools/templates/release.yml" "$path/.github/workflows/release.yml" + echo "release.yml créé : à commiter." +fi + +status=0 +if [ ! -f "$path/.distignore" ]; then + echo "À faire : créer $path/.distignore (fichiers exclus du paquet)." >&2 + status=1 +fi +if ! grep -qE '^[[:space:]*#@/]*Update URI:[[:space:]]*https://updates\.wearewp\.pro[[:space:]]*$' "$path/$slug.php" 2>/dev/null; then + echo "À faire : ajouter l'en-tête « Update URI: https://updates.wearewp.pro » dans $slug.php." >&2 + status=1 +fi +if [ ! -f "$path/wawp-updater/load.php" ]; then + echo "À faire : copier wawp-updater/ dans le plugin et ajouter le chargement (voir docs/mise-en-place.md)." >&2 + status=1 +fi +exit "$status" diff --git a/tests/Unit/SetupRepoTest.php b/tests/Unit/SetupRepoTest.php new file mode 100644 index 0000000..5b73760 --- /dev/null +++ b/tests/Unit/SetupRepoTest.php @@ -0,0 +1,50 @@ + Date: Mon, 12 Oct 2026 14:02:32 +0200 Subject: [PATCH 11/23] =?UTF-8?q?Validation=20bout=20en=20bout=20sur=20un?= =?UTF-8?q?=20vrai=20WordPress=20contre=20le=20serveur=20de=20mises=20?= =?UTF-8?q?=C3=A0=20jour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../e2e/fixture/wawp-test-plugin/.distignore | 2 + tests/e2e/fixture/wawp-test-plugin/readme.txt | 16 + .../wawp-test-plugin/wawp-test-plugin.php | 13 + tests/e2e/run.php | 303 ++++++++++++++++++ tests/e2e/versions.json | 5 + 5 files changed, 339 insertions(+) create mode 100644 tests/e2e/fixture/wawp-test-plugin/.distignore create mode 100644 tests/e2e/fixture/wawp-test-plugin/readme.txt create mode 100644 tests/e2e/fixture/wawp-test-plugin/wawp-test-plugin.php create mode 100644 tests/e2e/run.php create mode 100644 tests/e2e/versions.json diff --git a/tests/e2e/fixture/wawp-test-plugin/.distignore b/tests/e2e/fixture/wawp-test-plugin/.distignore new file mode 100644 index 0000000..56d7fae --- /dev/null +++ b/tests/e2e/fixture/wawp-test-plugin/.distignore @@ -0,0 +1,2 @@ +/.distignore +/tests diff --git a/tests/e2e/fixture/wawp-test-plugin/readme.txt b/tests/e2e/fixture/wawp-test-plugin/readme.txt new file mode 100644 index 0000000..2b4d9af --- /dev/null +++ b/tests/e2e/fixture/wawp-test-plugin/readme.txt @@ -0,0 +1,16 @@ +=== WAWP Test Plugin === +Requires at least: 5.8 +Tested up to: 6.8 +Requires PHP: 7.4 +Stable tag: {{VERSION}} + +Plugin de validation de la chaîne de mise à jour. + +== Description == + +Sert uniquement aux tests. + +== Changelog == + += {{VERSION}} = +* Version de test. diff --git a/tests/e2e/fixture/wawp-test-plugin/wawp-test-plugin.php b/tests/e2e/fixture/wawp-test-plugin/wawp-test-plugin.php new file mode 100644 index 0000000..c73f689 --- /dev/null +++ b/tests/e2e/fixture/wawp-test-plugin/wawp-test-plugin.php @@ -0,0 +1,13 @@ + ['file', PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, array_merge(getenv(), $env)); + if (!is_resource($process)) { + return [1, 'lancement impossible']; + } + $out = (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return [proc_close($process), $out]; +}; + +$remove = static function (string $path) use (&$remove): void { + if (is_file($path) || is_link($path)) { + @unlink($path); + return; + } + if (!is_dir($path)) { + return; + } + foreach (scandir($path) ?: [] as $entry) { + if ($entry !== '.' && $entry !== '..') { + $remove("$path/$entry"); + } + } + @rmdir($path); +}; + +$copy = static function (string $source, string $target) use (&$copy): void { + if (is_file($source)) { + @mkdir(dirname($target), 0777, true); + copy($source, $target); + return; + } + @mkdir($target, 0777, true); + foreach (scandir($source) ?: [] as $entry) { + if ($entry !== '.' && $entry !== '..') { + $copy("$source/$entry", "$target/$entry"); + } + } +}; + +$fetch = static function (string $url, string $dest, string $algo, string $expected): void { + if (!is_file($dest)) { + @mkdir(dirname($dest), 0777, true); + $curl = curl_init($url); + $handle = fopen("$dest.part", 'wb'); + curl_setopt_array($curl, [CURLOPT_FILE => $handle, CURLOPT_FOLLOWLOCATION => true, CURLOPT_FAILONERROR => true, CURLOPT_TIMEOUT => 300]); + $ok = curl_exec($curl); + $error = curl_error($curl); + fclose($handle); + if ($ok !== true) { + @unlink("$dest.part"); + throw new RuntimeException("Téléchargement impossible ($url) : $error"); + } + rename("$dest.part", $dest); + } + if (!hash_equals(strtolower($expected), hash_file($algo, $dest))) { + @unlink($dest); + throw new RuntimeException("Empreinte $algo incorrecte pour $dest"); + } +}; + +$unzip = static function (string $zip, string $target): void { + $archive = new ZipArchive(); + if ($archive->open($zip) !== true || !$archive->extractTo($target)) { + throw new RuntimeException("Extraction impossible : $zip"); + } + $archive->close(); +}; + +// 1. Téléchargements vérifiés. +$wpZip = "$cache/wordpress-{$versions['wordpress']['version']}.zip"; +$sqliteZip = "$cache/sqlite-{$versions['sqlite']['version']}.zip"; +$wpcli = "$cache/wp-cli-{$versions['wpcli']['version']}.phar"; +$fetch("https://wordpress.org/wordpress-{$versions['wordpress']['version']}.zip", $wpZip, 'sha1', $versions['wordpress']['sha1']); +$fetch($versions['sqlite']['url'], $sqliteZip, 'sha256', $versions['sqlite']['sha256']); +$fetch("https://github.com/wp-cli/wp-cli/releases/download/v{$versions['wpcli']['version']}/wp-cli-{$versions['wpcli']['version']}.phar", $wpcli, 'sha512', $versions['wpcli']['sha512']); + +$remove($tmp); +mkdir($tmp, 0777, true); + +// 2. Serveur de mises à jour, arborescence de production, adresse locale. +$web = "$tmp/srv/updates.wearewp.pro"; +$data = "$tmp/srv/updates-data"; +$copy("$serverRepo/public/index.php", "$web/index.php"); +$copy("$serverRepo/app", "$data/app"); +$copy("$serverRepo/bin", "$data/bin"); +file_put_contents("$data/app/config.php", " 1, 'licences' => [[ + 'id' => 'lic_e2eclient', 'key_hash' => $hasher->hash($key), 'client' => 'E2E', 'domains' => ['127.0.0.1'], + 'plugins' => [$slug], 'expires' => null, 'active' => $active, + ]]], JSON_PRETTY_PRINT)); +}; +$writeLicences(true); + +// 3. Construction et publication d'une version du plugin de test. +$makePlugin = static function (string $version, string $target) use ($e2e, $root, $copy, $endpoint, $public): void { + foreach (['wawp-test-plugin.php', 'readme.txt', '.distignore'] as $file) { + @mkdir($target, 0777, true); + file_put_contents("$target/$file", str_replace('{{VERSION}}', $version, (string) file_get_contents("$e2e/fixture/wawp-test-plugin/$file"))); + } + $copy("$root/wawp-updater", "$target/wawp-updater"); + $config = (string) file_get_contents("$target/wawp-updater/src/class-config.php"); + $config = (string) preg_replace("/const ENDPOINT = '[^']*';/", "const ENDPOINT = '$endpoint';", $config); + $config = (string) preg_replace('/const PUBLIC_KEYS = array\([^)]*\);/', "const PUBLIC_KEYS = array( '$public' );", $config); + file_put_contents("$target/wawp-updater/src/class-config.php", $config); +}; + +$publish = static function (string $version, bool $corruptAfter = false) use ($tmp, $data, $slug, $makePlugin, $remove, $run, $root, $secret): bool { + $build = "$tmp/build/$slug"; + $remove("$tmp/build"); + $makePlugin($version, $build); + @unlink("$build/.distignore"); + $zipPath = "$data/incoming/$slug-$version.zip"; + $zip = new ZipArchive(); + $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($build, FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + $relative = str_replace('\\', '/', substr($file->getPathname(), strlen("$tmp/build/"))); + $zip->addFile($file->getPathname(), $relative); + } + $zip->close(); + [$signCode] = $run([PHP_BINARY, "$root/scripts/sign.php", $zipPath], ['SIGNING_KEY' => $secret]); + [$ingestCode] = $run([PHP_BINARY, "$data/bin/ingest.php"]); + $published = is_file("$data/packages/$slug/$slug-$version.zip"); + if ($corruptAfter && $published) { + file_put_contents("$data/packages/$slug/$slug-$version.zip", 'corruption', FILE_APPEND); + } + + return $signCode === 0 && $ingestCode === 0 && $published; +}; + +// 4. WordPress avec SQLite. +$wpDir = "$tmp/wp"; +$unzip($wpZip, $tmp); +rename("$tmp/wordpress", $wpDir); +$unzip($sqliteZip, "$wpDir/wp-content/plugins"); +$dbCopy = (string) file_get_contents("$wpDir/wp-content/plugins/sqlite-database-integration/db.copy"); +file_put_contents("$wpDir/wp-content/db.php", str_replace( + ['{SQLITE_IMPLEMENTATION_FOLDER_PATH}', '{SQLITE_PLUGIN}'], + [str_replace('\\', '/', "$wpDir/wp-content/plugins/sqlite-database-integration"), 'sqlite-database-integration/load.php'], + $dbCopy +)); +$salts = ''; +foreach (['AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT'] as $salt) { + $salts .= "define( '$salt', '" . bin2hex(random_bytes(32)) . "' );\n"; +} +file_put_contents("$wpDir/wp-config.php", " array(), 'body' => '{\"plugins\":[],\"translations\":[],\"no_update\":[],\"themes\":[]}', 'response' => array( 'code' => 200, 'message' => 'OK' ), 'cookies' => array(), 'filename' => null ); }\n" + . "\treturn new WP_Error( 'e2e_reseau_bloque', \$host );\n" + . "}, 10, 3 );\n"); + +$wp = static function (string ...$args) use ($run, $wpcli, $wpDir): array { + return $run(array_merge([PHP_BINARY, $wpcli, "--path=$wpDir", '--skip-themes'], $args)); +}; +[$code, $out] = $wp('core', 'install', '--url=http://127.0.0.1:8768', '--title=E2E', '--admin_user=e2e', '--admin_password=' . bin2hex(random_bytes(16)), '--admin_email=e2e@example.test', '--skip-email'); +$check('installation de WordPress (SQLite)', $code === 0, $out); + +$makePlugin('1.0.0', "$wpDir/wp-content/plugins/$slug"); +[$code, $out] = $wp('plugin', 'activate', $slug); +$check('activation du plugin de test 1.0.0', $code === 0, $out); + +$version = static function () use ($wp, $slug): string { + return trim($wp('plugin', 'get', $slug, '--field=version')[1]); +}; +$updateVersion = static function () use ($wp, $slug): string { + [, $out] = $wp('plugin', 'list', "--name=$slug", '--fields=update_version', '--format=json'); + $list = json_decode($out, true); + + return is_array($list) && isset($list[0]['update_version']) ? (string) $list[0]['update_version'] : ''; +}; +$flush = static function () use ($wp): void { + $wp('eval', 'delete_site_transient( "wawp_updater_check" ); delete_site_transient( "update_plugins" );'); +}; + +// 5. Serveur PHP intégré pour l'API de mises à jour. +$server = proc_open([PHP_BINARY, '-S', "127.0.0.1:$port", '-t', $web, "$web/index.php"], [1 => ['file', "$tmp/server.log", 'a'], 2 => ['file', "$tmp/server.log", 'a']], $pipes); +for ($i = 0; $i < 50; $i++) { + $socket = @fsockopen('127.0.0.1', $port); + if ($socket !== false) { + fclose($socket); + break; + } + usleep(100000); +} + +try { + // a. Une mise à jour signée est proposée puis installée. + $check('publication 1.1.0', $publish('1.1.0')); + $check('mise à jour 1.1.0 proposée', $updateVersion() === '1.1.0'); + [$code, $out] = $wp('plugin', 'update', $slug); + $check('mise à jour 1.1.0 installée', $code === 0 && $version() === '1.1.0', $out); + + [, $leak] = $wp('eval', 'global $wpdb; echo (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->options WHERE option_value LIKE %s", "%" . WAWP_LICENSE_KEY . "%" ) );'); + $check('clé absente de la base', trim($leak) === '0', $leak); + + // b. Jeton périmé en cache : le téléchargement obtient un jeton frais. + $check('publication 1.2.0', $publish('1.2.0')); + $flush(); + $check('mise à jour 1.2.0 proposée', $updateVersion() === '1.2.0'); + $wp('eval', '$old = get_site_transient( "wawp_updater_check" ); $data = json_decode( $old["body"], true ); foreach ( array_keys( $data["plugins"] ) as $s ) { $data["plugins"][ $s ]["package"] = "' . $endpoint . '/v1/download/perime.jeton"; } $old["body"] = wp_json_encode( $data ); set_site_transient( "wawp_updater_check", $old, 3600 );' + . '$t = get_site_transient( "update_plugins" ); $t->response["' . $basename . '"]->package = "' . $endpoint . '/v1/download/perime.jeton"; set_site_transient( "update_plugins", $t );'); + [$code, $out] = $wp('plugin', 'update', $slug); + $check('mise à jour 1.2.0 avec jeton frais', $code === 0 && $version() === '1.2.0', $out); + + // c. Paquet altéré sur le serveur : signature refusée, plugin intact. + $check('publication 1.3.0 puis altération', $publish('1.3.0', true)); + $flush(); + [$code, $out] = $wp('plugin', 'update', $slug); + $check('paquet altéré refusé', $code !== 0 && str_contains($out, 'Signature du paquet WeAre[WP] invalide') && $version() === '1.2.0', $out); + $check('plugin toujours présent et actif', is_file("$wpDir/wp-content/plugins/$basename") && trim($wp('plugin', 'is-active', $slug)[1]) === '' && $wp('plugin', 'is-active', $slug)[0] === 0); + + // d. Licence révoquée : plus aucune mise à jour proposée. + $writeLicences(false); + $flush(); + $check('licence révoquée : aucune mise à jour', $updateVersion() === ''); + $writeLicences(true); + + // e. Clé retirée de wp-config.php : aucune requête vers le serveur. + $logs = static function () use ($data): int { + $count = 0; + foreach (glob("$data/logs/access-*.log") ?: [] as $file) { + $count += count(file($file) ?: []); + } + return $count; + }; + $wp('config', 'delete', 'WAWP_LICENSE_KEY'); + $flush(); + $before = $logs(); + $check('sans clé : aucune mise à jour', $updateVersion() === ''); + $check('sans clé : aucune requête au serveur', $logs() === $before); +} finally { + if (is_resource($server)) { + proc_terminate($server); + proc_close($server); + } +} + +echo $failures === 0 ? "\nTous les scénarios passent.\n" : "\n$failures scénario(s) en échec. Journaux : $tmp/server.log et $data/logs/\n"; +exit($failures === 0 ? 0 : 1); diff --git a/tests/e2e/versions.json b/tests/e2e/versions.json new file mode 100644 index 0000000..6d1d9e7 --- /dev/null +++ b/tests/e2e/versions.json @@ -0,0 +1,5 @@ +{ + "wordpress": { "version": "7.1.3", "sha1": "13eb1d042b1ca39224e44906cd84fe203112210c" }, + "sqlite": { "version": "3.1.0", "url": "https://downloads.wordpress.org/plugin/sqlite-database-integration.3.1.0.zip", "sha256": "e260e3ada78676f1c0f4e09eb9ebcb9ecce4d4b85f0c6e5a70fb9a93f5b9847f" }, + "wpcli": { "version": "2.12.0", "sha512": "be928f6b8ca1e8dfb9d2f4b75a13aa4aee0896f8a9a0a1c45cd5d2c98605e6172e6d014dda2e27f88c98befc16c040cbb2bd1bfa121510ea5cdf5f6a30fe8832" } +} From 1008d7ee390afc19a45902991fa8f30a7a7cd191 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 14:07:02 +0200 Subject: [PATCH 12/23] =?UTF-8?q?E2E=20:=20preuve=20du=20jeton=20p=C3=A9ri?= =?UTF-8?q?m=C3=A9=20et=20contr=C3=B4les=20positifs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 1 + tests/e2e/run.php | 84 ++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 69 insertions(+), 16 deletions(-) diff --git a/composer.json b/composer.json index 62b589b..a1bac45 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true }, + "process-timeout": 900, "sort-packages": true }, "scripts": { diff --git a/tests/e2e/run.php b/tests/e2e/run.php index a0ba269..2cf974b 100644 --- a/tests/e2e/run.php +++ b/tests/e2e/run.php @@ -19,8 +19,12 @@ $slug = 'wawp-test-plugin'; $basename = "$slug/$slug.php"; $failures = 0; +$masks = array(); -$check = static function (string $label, bool $ok, string $detail = '') use (&$failures): void { +$check = static function (string $label, bool $ok, string $detail = '') use (&$failures, &$masks): void { + foreach ($masks as $mask) { + $detail = str_replace($mask, '[masqué]', $detail); + } echo ($ok ? 'OK ' : 'ÉCHEC ') . $label . ($ok || $detail === '' ? '' : "\n $detail") . "\n"; if (!$ok) { $failures++; @@ -127,6 +131,7 @@ require "$data/app/autoload.php"; $key = \WAWP\UpdateServer\Licence\KeyGenerator::key(); +$masks[] = $key; $hasher = new \WAWP\UpdateServer\Licence\KeyHasher(\WAWP\UpdateServer\Secrets::loadHex("$data/secrets/key_pepper")); $writeLicences = static function (bool $active) use ($data, $hasher, $key, $slug): void { file_put_contents("$data/licences.json", (string) json_encode(['schema' => 1, 'licences' => [[ @@ -136,6 +141,20 @@ }; $writeLicences(true); +/** @return list Toutes les lignes des journaux d'accès du serveur. */ +$accessLines = static function () use ($data): array { + $lines = array(); + $files = glob("$data/logs/access-*.log") ?: array(); + sort($files); + foreach ($files as $file) { + foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: array() as $line) { + $lines[] = $line; + } + } + + return $lines; +}; + // 3. Construction et publication d'une version du plugin de test. $makePlugin = static function (string $version, string $target) use ($e2e, $root, $copy, $endpoint, $public): void { foreach (['wawp-test-plugin.php', 'readme.txt', '.distignore'] as $file) { @@ -226,25 +245,45 @@ $version = static function () use ($wp, $slug): string { return trim($wp('plugin', 'get', $slug, '--field=version')[1]); }; +// Renvoie la version proposée ('' si aucune) ou '#erreur' si WP-CLI échoue ou renvoie un JSON sans l'entrée du plugin. $updateVersion = static function () use ($wp, $slug): string { - [, $out] = $wp('plugin', 'list', "--name=$slug", '--fields=update_version', '--format=json'); - $list = json_decode($out, true); + [$code, $out] = $wp('plugin', 'list', "--name=$slug", '--fields=name,update_version', '--format=json'); + $list = $code === 0 ? json_decode($out, true) : null; + if (!is_array($list) || !isset($list[0]['name'], $list[0]['update_version']) || $list[0]['name'] !== $slug) { + return '#erreur'; + } - return is_array($list) && isset($list[0]['update_version']) ? (string) $list[0]['update_version'] : ''; + return (string) $list[0]['update_version']; }; $flush = static function () use ($wp): void { $wp('eval', 'delete_site_transient( "wawp_updater_check" ); delete_site_transient( "update_plugins" );'); }; // 5. Serveur PHP intégré pour l'API de mises à jour. +$probe = @fsockopen('127.0.0.1', $port); +if ($probe !== false) { + fclose($probe); + fwrite(STDERR, "Le port $port répond déjà : un autre serveur occupe l'adresse de test.\n"); + exit(1); +} $server = proc_open([PHP_BINARY, '-S', "127.0.0.1:$port", '-t', $web, "$web/index.php"], [1 => ['file', "$tmp/server.log", 'a'], 2 => ['file', "$tmp/server.log", 'a']], $pipes); -for ($i = 0; $i < 50; $i++) { +$listening = false; +for ($i = 0; $i < 50 && !$listening; $i++) { $socket = @fsockopen('127.0.0.1', $port); if ($socket !== false) { fclose($socket); - break; + $listening = true; + } else { + usleep(100000); } - usleep(100000); +} +if (!$listening) { + if (is_resource($server)) { + proc_terminate($server); + proc_close($server); + } + fwrite(STDERR, "Le serveur PHP intégré ne répond pas sur le port $port.\n"); + exit(1); } try { @@ -254,17 +293,28 @@ [$code, $out] = $wp('plugin', 'update', $slug); $check('mise à jour 1.1.0 installée', $code === 0 && $version() === '1.1.0', $out); - [, $leak] = $wp('eval', 'global $wpdb; echo (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->options WHERE option_value LIKE %s", "%" . WAWP_LICENSE_KEY . "%" ) );'); - $check('clé absente de la base', trim($leak) === '0', $leak); + [$leakCode, $leak] = $wp('eval', 'global $wpdb; echo (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->options WHERE option_value LIKE %s", "%" . WAWP_LICENSE_KEY . "%" ) ), "|", (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->options WHERE option_name LIKE %s", "%wawp_updater_check%" ) );'); + $check('clé absente de la base (contrôle positif : cache présent)', $leakCode === 0 && preg_match('/^0\|([1-9]\d*)$/', trim($leak)) === 1, $leak); // b. Jeton périmé en cache : le téléchargement obtient un jeton frais. $check('publication 1.2.0', $publish('1.2.0')); $flush(); $check('mise à jour 1.2.0 proposée', $updateVersion() === '1.2.0'); - $wp('eval', '$old = get_site_transient( "wawp_updater_check" ); $data = json_decode( $old["body"], true ); foreach ( array_keys( $data["plugins"] ) as $s ) { $data["plugins"][ $s ]["package"] = "' . $endpoint . '/v1/download/perime.jeton"; } $old["body"] = wp_json_encode( $data ); set_site_transient( "wawp_updater_check", $old, 3600 );' + [$injectCode, $injectOut] = $wp('eval', '$old = get_site_transient( "wawp_updater_check" ); $data = json_decode( $old["body"], true ); foreach ( array_keys( $data["plugins"] ) as $s ) { $data["plugins"][ $s ]["package"] = "' . $endpoint . '/v1/download/perime.jeton"; } $old["body"] = wp_json_encode( $data ); set_site_transient( "wawp_updater_check", $old, 3600 );' . '$t = get_site_transient( "update_plugins" ); $t->response["' . $basename . '"]->package = "' . $endpoint . '/v1/download/perime.jeton"; set_site_transient( "update_plugins", $t );'); + $check('injection du jeton périmé en cache', $injectCode === 0, $injectOut); + [$readCode, $readOut] = $wp('eval', '$t = get_site_transient( "update_plugins" ); $c = get_site_transient( "wawp_updater_check" ); echo ( isset( $t->response["' . $basename . '"] ) && false !== strpos( (string) $t->response["' . $basename . '"]->package, "perime.jeton" ) ) ? "A" : "a", ( is_array( $c ) && false !== strpos( (string) $c["body"], "perime.jeton" ) ) ? "B" : "b";'); + $check('jeton périmé bien présent dans les deux caches avant mise à jour', $readCode === 0 && trim($readOut) === 'AB', $readOut); + $linesBefore = count($accessLines()); [$code, $out] = $wp('plugin', 'update', $slug); $check('mise à jour 1.2.0 avec jeton frais', $code === 0 && $version() === '1.2.0', $out); + $newLines = array_slice($accessLines(), $linesBefore); + $joined = implode("\n", $newLines); + $downloads = array_filter($newLines, static function (string $line): bool { + return str_contains($line, '"event":"download"') && str_contains($line, '"version":"1.2.0"'); + }); + $check('journal : téléchargement 1.2.0 enregistré', count($downloads) >= 1, $joined); + $check('journal : jeton périmé jamais présenté', !str_contains($joined, 'perime.jeton') && !str_contains($joined, 'token_invalid') && !str_contains($joined, 'download_denied'), $joined); // c. Paquet altéré sur le serveur : signature refusée, plugin intact. $check('publication 1.3.0 puis altération', $publish('1.3.0', true)); @@ -276,20 +326,22 @@ // d. Licence révoquée : plus aucune mise à jour proposée. $writeLicences(false); $flush(); + $linesBefore = count($accessLines()); $check('licence révoquée : aucune mise à jour', $updateVersion() === ''); + $denied = array_filter(array_slice($accessLines(), $linesBefore), static function (string $line): bool { + return str_contains($line, '"event":"check_denied"') && str_contains($line, '"reason":"inactive"'); + }); + $check('licence révoquée : refus inactive journalisé', count($denied) >= 1); $writeLicences(true); // e. Clé retirée de wp-config.php : aucune requête vers le serveur. - $logs = static function () use ($data): int { - $count = 0; - foreach (glob("$data/logs/access-*.log") ?: [] as $file) { - $count += count(file($file) ?: []); - } - return $count; + $logs = static function () use ($accessLines): int { + return count($accessLines()); }; $wp('config', 'delete', 'WAWP_LICENSE_KEY'); $flush(); $before = $logs(); + $check("journal d'accès non vide avant le contrôle sans clé", $before > 0); $check('sans clé : aucune mise à jour', $updateVersion() === ''); $check('sans clé : aucune requête au serveur', $logs() === $before); } finally { From ef1d3abc1945ddf2283581bf88e8a6f4c0f2f6d2 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 14:09:15 +0200 Subject: [PATCH 13/23] =?UTF-8?q?Int=C3=A9gration=20continue=20et=20proc?= =?UTF-8?q?=C3=A9dure=20de=20mise=20en=20place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 38 ++++++++++++++++++ README.md | 13 ++++++ docs/mise-en-place.md | 87 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 README.md create mode 100644 docs/mise-en-place.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..554c1b7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Extensions PHP + run: | + php -v + for ext in json sodium zip curl; do php -m | grep -qx "$ext" || { echo "Extension manquante : $ext"; exit 1; }; done + + - name: Dépendances + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Tests unitaires + run: composer test + + - name: Analyse statique + run: composer stan + + - name: Standards WordPress et compatibilité PHP 7.4 + run: composer cs diff --git a/README.md b/README.md new file mode 100644 index 0000000..66558d4 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# release-tools + +Outils de publication des plugins WeAre[WP] et client de mise à jour `wawp-updater`. + +- `wawp-updater/` : bibliothèque embarquée dans chaque plugin (PHP 7.4+, WordPress 5.8+). Vérifie les mises à jour sur `updates.wearewp.pro` avec la clé `WAWP_LICENSE_KEY` et refuse tout paquet dont la signature Ed25519 est invalide. +- `.github/workflows/plugin-release.yml` : workflow réutilisable, déclenché par un tag `vX.Y.Z` dans le dépôt d'un plugin. +- `scripts/`, `bin/` : contrôles, signature, envoi FTPS, génération des clés, préparation d'un dépôt. + +Ce dépôt ne contient aucun secret. Mise en place : `docs/mise-en-place.md`. + +Développement : `composer test`, `composer stan`, `composer cs`, `composer e2e` (nécessite le dépôt `updates-server` à côté). + +Licence : GPL-2.0-or-later. diff --git a/docs/mise-en-place.md b/docs/mise-en-place.md new file mode 100644 index 0000000..f45446d --- /dev/null +++ b/docs/mise-en-place.md @@ -0,0 +1,87 @@ +# Mise en place de la publication des plugins WeAre[WP] + +Procédure unique, à suivre dans l'ordre. Les étapes marquées 🔒 manipulent des secrets : Thierry les exécute lui-même, dans son propre terminal, jamais dans une session automatisée. + +## 1. Clés de signature 🔒 + +Dans un terminal local, depuis ce dépôt : + +``` +php bin/keygen.php +``` + +- Clé privée ACTIVE : la copier dans le gestionnaire de mots de passe, puis dans le secret d'organisation `SIGNING_KEY` (étape 2). +- Clé privée de SECOURS : uniquement dans le gestionnaire de mots de passe. Jamais sur GitHub, jamais sur un serveur. +- Les deux clés publiques : à transmettre pour l'étape 4 (elles ne sont pas secrètes). + +Effacer ensuite le terminal (`clear`) et fermer la fenêtre. + +## 2. Secrets d'organisation GitHub 🔒 + +Pour chaque secret, la commande demande la valeur de façon masquée : + +``` +gh secret set SIGNING_KEY --org wearewp --visibility selected --repos wawp-test-plugin +gh secret set FTP_HOST --org wearewp --visibility selected --repos wawp-test-plugin +gh secret set FTP_USER --org wearewp --visibility selected --repos wawp-test-plugin +gh secret set FTP_PASSWORD --org wearewp --visibility selected --repos wawp-test-plugin +``` + +Les secrets d'organisation doivent impérativement être créés avec `--visibility selected` : sinon `bin/setup-repo.sh` ne peut pas y ajouter de dépôts. + +`FTP_HOST`, `FTP_USER`, `FTP_PASSWORD` : le compte FTP dédié créé dans cPanel, limité à `updates-data/incoming`. Les autres dépôts de plugins sont ajoutés ensuite par `bin/setup-repo.sh` (étape 6), sans jamais retaper les valeurs. + +## 3. Serveur o2switch + +- Créer `~/updates-data/signing.pub` avec le bloc « Contenu de ~/updates-data/signing.pub » affiché par `keygen.php` (clé active puis clé de secours). +- Ajouter maintenant la tâche cron d'ingestion : `*/5 * * * * ~/updates-data/bin/ingest.php` (voir `docs/deploiement-o2switch.md` du dépôt updates-server). +- Lancer une fois ` ~/updates-data/bin/ingest.php` : aucune sortie, code 0. + +## 4. Clés publiques dans wawp-updater + +Placer la ligne `const PUBLIC_KEYS = array( '…', '…' );` affichée par `keygen.php` dans `wawp-updater/src/class-config.php`, faire relire, fusionner, puis créer le tag `v1` de ce dépôt (protégé par un ruleset : il ne pourra plus être déplacé). + +## 5. Ruleset des tags de release-tools + +Le tag `v1` est la référence utilisée par tous les plugins. Le protéger avant de le créer : + +``` +gh api -X POST repos/wearewp/release-tools/rulesets --input - <<'JSON' +{ "name": "Tags de version immuables", "target": "tag", "enforcement": "active", + "conditions": { "ref_name": { "include": ["refs/tags/v*"], "exclude": [] } }, + "rules": [ { "type": "deletion" }, { "type": "update" }, { "type": "non_fast_forward" } ] } +JSON +``` + +## 6. Préparer un dépôt de plugin + +Le dépôt doit appartenir à l'organisation `wearewp`, être privé et porter le nom du slug du plugin. + +1. Copier `wawp-updater/` (version taguée `v1`) à la racine du plugin. +2. Dans le fichier principal `.php` : ajouter l'en-tête `Update URI: https://updates.wearewp.pro` et, après le contrôle `ABSPATH` : + ```php + require __DIR__ . '/wawp-updater/load.php'; + \WAWP\Updater\register( __FILE__ ); + ``` +3. Créer `.distignore` (fichiers exclus du paquet : `/.git`, `/.github`, `/tests`, `/node_modules`, fichiers de configuration de développement…). +4. Retirer Plugin Update Checker et les en-têtes Git Updater s'il y en a. +5. Lancer : `bash bin/setup-repo.sh wearewp/ `, puis commiter `.github/workflows/release.yml`. + +## 7. Publier une version + +Mettre à jour l'en-tête `Version` et le `Stable tag` du `readme.txt`, commiter, puis : + +``` +git tag vX.Y.Z +git push origin vX.Y.Z +``` + +Le workflow contrôle, construit, signe, envoie sur o2switch et archive le paquet dans la Release GitHub. Le cron d'ingestion le publie dans les 5 minutes ; les sites le voient à leur prochaine vérification (12 h au plus, ou « Rechercher les mises à jour »). + +## 8. Rotation de la clé de signature (fuite de la clé active) + +Suivre la section 11 de la spec : passer `SIGNING_KEY` sur la clé de secours, générer une nouvelle paire de secours, publier une version de `wawp-updater` dont `PUBLIC_KEYS` vaut `array( ancienne clé de secours, nouvelle clé de secours )`, l'intégrer dans tous les plugins et les publier, puis retirer l'ancienne clé publique de `signing.pub`. + +## Limite connue + +WordPress n'appelle les filtres `Update URI` qu'après une réponse réussie d'`api.wordpress.org`. Si ce service est injoignable depuis un site, les mises à jour WeAre[WP] n'y apparaissent pas tant que la situation dure ; elles réapparaissent dès le contrôle suivant. From 086b1962269df122a8cbb0520a73bd650af5ccd5 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 14:14:32 +0200 Subject: [PATCH 14/23] =?UTF-8?q?Proc=C3=A9dure=20de=20mise=20en=20place?= =?UTF-8?q?=20:=20ordre=20de=20protection=20du=20tag=20et=20pr=C3=A9cision?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mise-en-place.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/mise-en-place.md b/docs/mise-en-place.md index f45446d..19bd132 100644 --- a/docs/mise-en-place.md +++ b/docs/mise-en-place.md @@ -18,6 +18,8 @@ Effacer ensuite le terminal (`clear`) et fermer la fenêtre. ## 2. Secrets d'organisation GitHub 🔒 +Le dépôt privé `wearewp/wawp-test-plugin` doit déjà exister (plugin de test servant à valider la chaîne, créé à l'étape 6 ou lors de la mise en service avec Claude), sinon `--repos wawp-test-plugin` échoue. + Pour chaque secret, la commande demande la valeur de façon masquée : ``` @@ -39,11 +41,13 @@ Les secrets d'organisation doivent impérativement être créés avec `--visibil ## 4. Clés publiques dans wawp-updater -Placer la ligne `const PUBLIC_KEYS = array( '…', '…' );` affichée par `keygen.php` dans `wawp-updater/src/class-config.php`, faire relire, fusionner, puis créer le tag `v1` de ce dépôt (protégé par un ruleset : il ne pourra plus être déplacé). +Placer la ligne `const PUBLIC_KEYS = array( '…', '…' );` affichée par `keygen.php` dans `wawp-updater/src/class-config.php`, faire relire, puis fusionner. Le tag `v1` n'est pas créé ici : il l'est à l'étape 5, une fois protégé. ## 5. Ruleset des tags de release-tools -Le tag `v1` est la référence utilisée par tous les plugins. Le protéger avant de le créer : +Le tag `v1` est la référence utilisée par tous les plugins. Les commandes de cette section et de la suivante s'exécutent dans Git Bash, avec `gh` authentifié par un compte propriétaire de l'organisation `wearewp` (vérifier avec `gh auth status`). + +D'abord protéger les tags : ``` gh api -X POST repos/wearewp/release-tools/rulesets --input - <<'JSON' @@ -53,6 +57,14 @@ gh api -X POST repos/wearewp/release-tools/rulesets --input - <<'JSON' JSON ``` +Ensuite seulement, créer le tag depuis un clone de release-tools, sur `main` après la fusion de l'étape 4 : + +``` +git checkout main && git pull +git tag v1 +git push origin v1 +``` + ## 6. Préparer un dépôt de plugin Le dépôt doit appartenir à l'organisation `wearewp`, être privé et porter le nom du slug du plugin. @@ -65,7 +77,7 @@ Le dépôt doit appartenir à l'organisation `wearewp`, être privé et porter l ``` 3. Créer `.distignore` (fichiers exclus du paquet : `/.git`, `/.github`, `/tests`, `/node_modules`, fichiers de configuration de développement…). 4. Retirer Plugin Update Checker et les en-têtes Git Updater s'il y en a. -5. Lancer : `bash bin/setup-repo.sh wearewp/ `, puis commiter `.github/workflows/release.yml`. +5. Lancer, dans Git Bash, depuis le dossier de release-tools (`gh` authentifié comme à l'étape 5) : `bash bin/setup-repo.sh wearewp/ `, puis commiter `.github/workflows/release.yml`. ## 7. Publier une version @@ -80,7 +92,7 @@ Le workflow contrôle, construit, signe, envoie sur o2switch et archive le paque ## 8. Rotation de la clé de signature (fuite de la clé active) -Suivre la section 11 de la spec : passer `SIGNING_KEY` sur la clé de secours, générer une nouvelle paire de secours, publier une version de `wawp-updater` dont `PUBLIC_KEYS` vaut `array( ancienne clé de secours, nouvelle clé de secours )`, l'intégrer dans tous les plugins et les publier, puis retirer l'ancienne clé publique de `signing.pub`. +Suivre la section 11 de la spec (`docs/superpowers/specs/2026-10-12-serveur-mises-a-jour-design.md` dans le dépôt updates-server) : passer `SIGNING_KEY` sur la clé de secours avec `gh secret set SIGNING_KEY --org wearewp --visibility selected --repos ` (attention : `--repos` remplace la liste existante, y inclure tous les dépôts de plugins), générer une nouvelle paire de secours, publier une version de `wawp-updater` dont `PUBLIC_KEYS` vaut `array( ancienne clé de secours, nouvelle clé de secours )`, l'intégrer dans tous les plugins et les publier, puis retirer l'ancienne clé publique de `signing.pub`. ## Limite connue From 55ce92de978a235814a3dc5fb88486509cb22ac2 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 14:43:09 +0200 Subject: [PATCH 15/23] =?UTF-8?q?Client=20:=20aucune=20erreur=20fatale=20s?= =?UTF-8?q?i=20un=20fichier=20manque=20;=20classe=20Package=20de=20contr?= =?UTF-8?q?=C3=B4le=20du=20paquet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - boot() charge la copie la plus récente dont bootstrap.php est lisible, sinon ne fait rien ; - bootstrap.php vérifie les six fichiers src/ avant tout require et renvoie sinon une fonction sans effet ; - load.php ne laisse plus que le registre en portée globale ; - Package::matches() lit le zip (ZipArchive, sinon PclZip du cœur) : entrées sous / et en-tête Version. --- phpstan.neon.dist | 2 + tests/Unit/BootstrapTest.php | 75 ++++++++++++++++ tests/Unit/LoaderTest.php | 32 +++++++ tests/Unit/PackageTest.php | 132 +++++++++++++++++++++++++++ tests/bootstrap.php | 2 +- tests/phpstan/pclzip.php | 5 ++ wawp-updater/bootstrap.php | 9 ++ wawp-updater/load.php | 19 ++-- wawp-updater/src/class-package.php | 138 +++++++++++++++++++++++++++++ 9 files changed, 406 insertions(+), 8 deletions(-) create mode 100644 tests/Unit/BootstrapTest.php create mode 100644 tests/Unit/PackageTest.php create mode 100644 tests/phpstan/pclzip.php create mode 100644 wawp-updater/src/class-package.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 60d0bef..bac3a86 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -7,3 +7,5 @@ parameters: - wawp-updater - scripts - bin + bootstrapFiles: + - tests/phpstan/pclzip.php diff --git a/tests/Unit/BootstrapTest.php b/tests/Unit/BootstrapTest.php new file mode 100644 index 0000000..24bcccf --- /dev/null +++ b/tests/Unit/BootstrapTest.php @@ -0,0 +1,75 @@ +dir = sys_get_temp_dir() . '/wawp-bootstrap-' . bin2hex(random_bytes(4)); + $source = dirname(__DIR__, 2) . '/wawp-updater'; + mkdir("{$this->dir}/src", 0777, true); + copy("$source/bootstrap.php", "{$this->dir}/bootstrap.php"); + foreach (self::CLASSES as $class) { + if (is_file("$source/src/class-$class.php")) { + copy("$source/src/class-$class.php", "{$this->dir}/src/class-$class.php"); + } + } + } + + protected function tearDown(): void + { + foreach (glob("{$this->dir}/src/*") ?: [] as $file) { + unlink($file); + } + @unlink("{$this->dir}/bootstrap.php"); + @rmdir("{$this->dir}/src"); + @rmdir($this->dir); + } + + private function start(): string + { + $code = 'require ' . var_export(dirname(__DIR__) . '/wp-stubs.php', true) . ';' + . '$start = require ' . var_export("{$this->dir}/bootstrap.php", true) . ';' + . '$start([WP_PLUGIN_DIR . "/wawp-demo/wawp-demo.php"]);' + . 'echo "hooks:" . count(WawpTestState::$hooks);'; + $process = proc_open([PHP_BINARY, '-r', $code], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + self::assertIsResource($process); + $out = (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + + return $out; + } + + public function testCompleteCopyRegistersHooks(): void + { + foreach (self::CLASSES as $class) { + self::assertFileExists("{$this->dir}/src/class-$class.php"); + } + self::assertMatchesRegularExpression('/^hooks:[1-9]\d*\z/', $this->start()); + } + + public function testMissingSourceFileRegistersNothingAndNeverFails(): void + { + foreach (self::CLASSES as $class) { + $this->tearDown(); + $this->setUp(); + unlink("{$this->dir}/src/class-$class.php"); + self::assertSame('hooks:0', $this->start(), "class-$class.php absent"); + } + } +} diff --git a/tests/Unit/LoaderTest.php b/tests/Unit/LoaderTest.php index bba7182..e1f005e 100644 --- a/tests/Unit/LoaderTest.php +++ b/tests/Unit/LoaderTest.php @@ -43,6 +43,12 @@ private function load(array $order): string } $code .= 'foreach ($GLOBALS["h"]["plugins_loaded"] as $c) { $c(); }' . 'echo "|" . count($GLOBALS["h"]["plugins_loaded"]);'; + + return self::subprocess($code); + } + + private static function subprocess(string $code): string + { $process = proc_open([PHP_BINARY, '-r', $code], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); self::assertIsResource($process); $out = (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]); @@ -59,6 +65,32 @@ public function testHighestVersionWinsWhateverTheOrder(): void self::assertSame('1.2.0:/plugins/p0/p0.php,/plugins/p1/p1.php|1', $this->load(['recent', 'ancien'])); } + public function testNewestCopyWithoutBootstrapFallsBackToTheNextOne(): void + { + unlink("{$this->dir}/recent/bootstrap.php"); + self::assertSame('1.0.0:/plugins/p0/p0.php,/plugins/p1/p1.php|1', $this->load(['ancien', 'recent'])); + self::assertSame('1.0.0:/plugins/p0/p0.php,/plugins/p1/p1.php|1', $this->load(['recent', 'ancien'])); + } + + public function testNoReadableCopyBootsNothingWithoutError(): void + { + unlink("{$this->dir}/recent/bootstrap.php"); + unlink("{$this->dir}/ancien/bootstrap.php"); + self::assertSame('|1', $this->load(['ancien', 'recent'])); + } + + public function testLoaderLeavesOnlyTheRegistryInGlobalScope(): void + { + $out = self::subprocess( + 'define("ABSPATH", "/");' + . 'function add_action($n, $c, $p = 10) {}' + . 'require ' . var_export("{$this->dir}/recent/load.php", true) . ';' + . 'echo isset($wawp_updater_version) ? "version " : "", isset($wawp_updater_copies) ? "copies " : "",' + . ' isset($GLOBALS["wawp_updater_registry"]["copies"]["1.2.0"]) ? "registre" : "";' + ); + self::assertSame('registre', $out); + } + public function testVersionConstantMatchesLoader(): void { $load = (string) file_get_contents(dirname(__DIR__, 2) . '/wawp-updater/load.php'); diff --git a/tests/Unit/PackageTest.php b/tests/Unit/PackageTest.php new file mode 100644 index 0000000..ba8e222 --- /dev/null +++ b/tests/Unit/PackageTest.php @@ -0,0 +1,132 @@ +dir = sys_get_temp_dir() . '/wawp-package-' . bin2hex(random_bytes(4)); + mkdir($this->dir, 0777, true); + } + + protected function tearDown(): void + { + $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($it as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->dir); + } + + /** @param array $entries */ + public static function zip(string $path, array $entries): string + { + $zip = new \ZipArchive(); + $zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + foreach ($entries as $name => $content) { + str_ends_with($name, '/') ? $zip->addEmptyDir(rtrim($name, '/')) : $zip->addFromString($name, $content); + } + $zip->close(); + + return $path; + } + + public static function main(string $version, string $eol = "\n"): string + { + return str_replace("\n", $eol, " */ + private function valid(string $version = '1.4.0'): array + { + return [ + 'wawp-demo/' => '', + 'wawp-demo/wawp-demo.php' => self::main($version), + 'wawp-demo/readme.txt' => "Stable tag: $version\n", + 'wawp-demo/wawp-updater/load.php' => 'dir}/a.zip", $this->valid()), 'wawp-demo', '1.4.0')); + self::assertTrue(Package::matches(self::zip("{$this->dir}/b.zip", ['wawp-demo/wawp-demo.php' => self::main('1.4.0', "\r\n")]), 'wawp-demo', '1.4.0'), 'fin de ligne CRLF'); + } + + public function testEachMismatchIsRefused(): void + { + $cases = [ + 'autre version' => [$this->valid('1.3.5'), 'wawp-demo', '1.4.0'], + 'autre slug' => [$this->valid(), 'wawp-autre', '1.4.0'], + 'entrée hors du dossier' => [$this->valid() + ['autre/x.php' => ' [$this->valid() + ['wawp-demox/x.php' => ' [['wawp-demo/readme.txt' => 'x'], 'wawp-demo', '1.4.0'], + 'en-tête au-delà de 8 Ko' => [['wawp-demo/wawp-demo.php' => " [$this->valid(), '../wawp-demo', '1.4.0'], + 'version vide' => [$this->valid(), 'wawp-demo', ''], + ]; + $i = 0; + foreach ($cases as $label => [$entries, $slug, $version]) { + $zip = self::zip("{$this->dir}/c" . $i++ . '.zip', $entries); + self::assertFalse(Package::matches($zip, $slug, $version), $label); + } + } + + public function testUnreadableArchiveIsRefused(): void + { + file_put_contents("{$this->dir}/faux.zip", random_bytes(512)); + self::assertFalse(Package::matches("{$this->dir}/faux.zip", 'wawp-demo', '1.4.0')); + self::assertFalse(Package::matches("{$this->dir}/absent.zip", 'wawp-demo', '1.4.0')); + } + + /** Sous-processus sans php.ini (php -n) : ZipArchive indisponible. */ + private function withoutZipArchive(string $abspath, string $zip, string $version): string + { + $src = dirname(__DIR__, 2) . '/wawp-updater/src'; + $code = 'define("ABSPATH", ' . var_export($abspath, true) . ');' + . 'require ' . var_export("$src/class-validator.php", true) . ';' + . 'require ' . var_export("$src/class-package.php", true) . ';' + . 'echo class_exists("ZipArchive", false) ? "zip:" : "", var_export(\WAWP\Updater\Package::matches(' . var_export($zip, true) . ', "wawp-demo", ' . var_export($version, true) . '), true);'; + $process = proc_open([PHP_BINARY, '-n', '-r', $code], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + self::assertIsResource($process); + $out = (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + + return $out; + } + + public function testWithoutZipArchiveNorPclZipThePackageIsRefused(): void + { + $zip = self::zip("{$this->dir}/a.zip", $this->valid()); + self::assertSame('false', $this->withoutZipArchive($this->dir . '/', $zip, '1.4.0')); + } + + public function testPclZipFromCoreIsUsedWhenZipArchiveIsMissing(): void + { + $wordpress = dirname(__DIR__) . '/e2e/cache/wordpress-' . json_decode((string) file_get_contents(dirname(__DIR__) . '/e2e/versions.json'), true)['wordpress']['version'] . '.zip'; + if (!is_file($wordpress)) { + self::markTestSkipped('Archive WordPress absente du cache e2e (lancer composer e2e une fois).'); + } + $core = new \ZipArchive(); + $core->open($wordpress); + mkdir("{$this->dir}/wp/wp-admin/includes", 0777, true); + file_put_contents("{$this->dir}/wp/wp-admin/includes/class-pclzip.php", (string) $core->getFromName('wordpress/wp-admin/includes/class-pclzip.php')); + $core->close(); + + $zip = self::zip("{$this->dir}/a.zip", $this->valid()); + self::assertSame('true', $this->withoutZipArchive("{$this->dir}/wp/", $zip, '1.4.0')); + self::assertSame('false', $this->withoutZipArchive("{$this->dir}/wp/", $zip, '1.5.0')); + $foreign = self::zip("{$this->dir}/b.zip", $this->valid() + ['autre/x.php' => 'withoutZipArchive("{$this->dir}/wp/", $foreign, '1.4.0')); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index b7a198a..043eedd 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -5,7 +5,7 @@ require dirname(__DIR__) . '/vendor/autoload.php'; require __DIR__ . '/wp-stubs.php'; -foreach (['config', 'validator', 'signature', 'client', 'updater'] as $class) { +foreach (['config', 'validator', 'signature', 'client', 'package', 'updater'] as $class) { $file = dirname(__DIR__) . "/wawp-updater/src/class-$class.php"; if (is_file($file)) { require_once $file; diff --git a/tests/phpstan/pclzip.php b/tests/phpstan/pclzip.php new file mode 100644 index 0000000..295ec33 --- /dev/null +++ b/tests/phpstan/pclzip.php @@ -0,0 +1,5 @@ +/` et l'en-tête Version de `/.php` vaut `$version`. + * + * @param string $file Archive zip téléchargée. + * @param string $slug Slug du plugin. + * @param string $version Version de l'offre. + */ + public static function matches( string $file, string $slug, string $version ): bool { + try { + if ( ! Validator::is_slug( $slug ) || '' === $version || ! is_file( $file ) ) { + return false; + } + $archive = self::read( $file, $slug . '/' . $slug . '.php' ); + if ( null === $archive ) { + return false; + } + foreach ( $archive['entries'] as $entry ) { + if ( 0 !== strpos( $entry, $slug . '/' ) ) { + return false; + } + } + + return self::header( $archive['main'], 'Version' ) === $version; + } catch ( \Throwable $e ) { + return false; + } + } + + /** + * Liste des entrées et début du fichier principal, par ZipArchive ou, à défaut, par PclZip du cœur. + * + * @param string $file Archive zip. + * @param string $main Chemin du fichier principal dans l'archive. + * @return array{entries: list, main: string}|null Null si l'archive est illisible ou le fichier absent. + */ + private static function read( string $file, string $main ): ?array { + if ( class_exists( 'ZipArchive' ) ) { + $zip = new \ZipArchive(); + if ( true !== $zip->open( $file ) ) { + return null; + } + $entries = array(); + $count = $zip->count(); + for ( $i = 0; $i < $count; $i++ ) { + $name = $zip->getNameIndex( $i ); + if ( false === $name ) { + $zip->close(); + return null; + } + $entries[] = $name; + } + $content = $zip->getFromName( $main, self::HEADER_BYTES ); + $zip->close(); + + return false === $content ? null : array( + 'entries' => $entries, + 'main' => $content, + ); + } + + if ( ! class_exists( 'PclZip' ) ) { + $pclzip = ABSPATH . 'wp-admin/includes/class-pclzip.php'; + if ( ! is_readable( $pclzip ) ) { + return null; + } + require_once $pclzip; + } + $archive = new \PclZip( $file ); + $list = $archive->listContent(); + if ( ! is_array( $list ) ) { + return null; + } + $entries = array(); + foreach ( $list as $item ) { + if ( ! is_array( $item ) || ! isset( $item['filename'] ) || ! is_string( $item['filename'] ) ) { + return null; + } + $entries[] = $item['filename']; + } + $extracted = self::call( array( $archive, 'extract' ), array( PCLZIP_OPT_BY_NAME, $main, PCLZIP_OPT_EXTRACT_AS_STRING ) ); + if ( ! is_array( $extracted ) || ! isset( $extracted[0] ) || ! is_array( $extracted[0] ) || ! isset( $extracted[0]['content'] ) || ! is_string( $extracted[0]['content'] ) ) { + return null; + } + + return array( + 'entries' => $entries, + 'main' => substr( $extracted[0]['content'], 0, self::HEADER_BYTES ), + ); + } + + /** + * Appel d'une méthode à options variadiques : PclZip lit ses options par func_get_args(), alors que les + * stubs WordPress déclarent extract() sans paramètre. + * + * @param callable $method Méthode à appeler. + * @param list $arguments Arguments. + * @return mixed + */ + private static function call( callable $method, array $arguments ) { + return call_user_func_array( $method, $arguments ); + } + + /** + * Valeur d'un en-tête, extraite comme get_file_data() de WordPress. + * + * @param string $content Début du fichier. + * @param string $name Nom de l'en-tête. + */ + private static function header( string $content, string $name ): ?string { + $content = str_replace( "\r", "\n", $content ); + if ( 1 !== preg_match( '/^(?:[ \t]*<\?php)?[ \t\/*#@]*' . preg_quote( $name, '/' ) . ':(.*)$/mi', $content, $match ) ) { + return null; + } + $value = trim( (string) preg_replace( '/\s*(?:\*\/|\?>).*/', '', $match[1] ) ); + + return '' === $value ? null : $value; + } +} From 810bf1cd193af4bc541e56cca8961e2225a88a15 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 14:43:10 +0200 Subject: [PATCH 16/23] =?UTF-8?q?Client=20:=20refus=20d'un=20paquet=20sign?= =?UTF-8?q?=C3=A9=20non=20conforme=20=C3=A0=20l'offre,=20entr=C3=A9e=20no?= =?UTF-8?q?=5Fupdate,=20paquet=20d=C3=A9tourn=C3=A9=20refus=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - après la signature, le paquet doit contenir / en version de l'offre, supérieure à l'installée (wawp_package_mismatch) ; - plugin à jour avec une clé valide : entrée sans paquet, rangée par WordPress dans no_update ; - upgrader_pre_download en priorité 1 : une URL WeAre[WP] déjà traitée par un autre filtre est refusée (wawp_package_hijacked). --- tests/Unit/UpdaterTest.php | 76 +++++++++++++++++++++++++----- wawp-updater/src/class-updater.php | 28 +++++++++-- 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/tests/Unit/UpdaterTest.php b/tests/Unit/UpdaterTest.php index dbc5d50..af3c448 100644 --- a/tests/Unit/UpdaterTest.php +++ b/tests/Unit/UpdaterTest.php @@ -32,26 +32,35 @@ protected function setUp(): void WawpTestState::$versions[self::FILE] = '1.3.0'; $this->pair = Keys::pair(); $this->zip = sys_get_temp_dir() . '/wawp-upd-' . bin2hex(random_bytes(4)) . '.zip'; - file_put_contents($this->zip, random_bytes(1024)); + $this->serve('1.4.0', 'wawp-demo', '1.4.0'); + $zip = $this->zip; + WawpTestState::$download = static function () use ($zip): string { + $copy = $zip . '.dl'; + copy($zip, $copy); + + return $copy; + }; + } + + /** Le serveur propose $offer ; le paquet signé contient le plugin $slug en version $version. */ + private function serve(string $offer, string $slug, string $version): void + { + if (is_file($this->zip)) { + unlink($this->zip); + } + PackageTest::zip($this->zip, ["$slug/$slug.php" => PackageTest::main($version), "$slug/readme.txt" => "Stable tag: $version\n"]); $signature = Keys::sign_file($this->zip, $this->pair['secret']); - WawpTestState::$http = function (string $method) use ($signature): array { + WawpTestState::$http = function (string $method) use ($signature, $offer): array { $this->token++; return $method === 'POST' - ? Http::response(200, Http::offer_body(['wawp-demo' => Http::offer('1.4.0', $signature, 'jeton' . $this->token)])) + ? Http::response(200, Http::offer_body(['wawp-demo' => Http::offer($offer, $signature, 'jeton' . $this->token)])) : Http::response(200, (string) json_encode([ 'name' => 'WAWP Démo', 'slug' => 'wawp-demo', 'version' => '1.4.0', 'requires' => '6.4', 'tested' => '6.8', 'requires_php' => '7.4', 'last_updated' => '2026-10-12', 'sections' => ['description' => '

    D

    ', 'changelog' => '

    1.4.0

    '], ])); }; - $zip = $this->zip; - WawpTestState::$download = static function () use ($zip): string { - $copy = $zip . '.dl'; - copy($zip, $copy); - - return $copy; - }; } protected function tearDown(): void @@ -101,10 +110,20 @@ public function testForeignPluginIsLeftAlone(): void public function testServerFailureIsCachedOneHourAndBreaksNothing(): void { WawpTestState::$http = static fn () => new WP_Error('http_request_failed', 'timeout'); - self::assertFalse($this->updater()->filter_update(false, [], self::BASENAME, [])); + $update = $this->updater()->filter_update(false, [], self::BASENAME, []); + self::assertSame('', $update['package'], 'aucun paquet proposé'); self::assertSame(3600, WawpTestState::$transients['wawp_updater_check']['ttl']); } + public function testUpToDatePluginIsReportedAsNoUpdate(): void + { + WawpTestState::$http = static fn () => Http::response(200, Http::offer_body([])); + self::assertSame( + ['slug' => 'wawp-demo', 'version' => '1.3.0', 'url' => 'https://updates.wearewp.pro', 'package' => ''], + $this->updater()->filter_update(false, [], self::BASENAME, []) + ); + } + public function testMultisiteSendsNetworkUrl(): void { WawpTestState::$multisite = true; @@ -147,11 +166,44 @@ public function testPreDownloadIgnoresForeignPackagesAndRefusesUnknownOnes(): vo { $updater = $this->updater(); self::assertFalse($updater->filter_pre_download(false, 'https://downloads.wordpress.org/plugin/x.zip', null, [])); - self::assertSame('deja', $updater->filter_pre_download('deja', 'https://updates.wearewp.pro/v1/download/x', null, [])); + self::assertSame('deja', $updater->filter_pre_download('deja', 'https://downloads.wordpress.org/plugin/x.zip', null, [])); $unknown = $updater->filter_pre_download(false, 'https://updates.wearewp.pro/v1/download/x', null, ['plugin' => 'autre/autre.php']); self::assertSame('wawp_unknown_package', $unknown->get_error_code()); } + public function testPreDownloadRefusesAPackageAlreadyHandledByAnotherFilter(): void + { + $updater = $this->updater(); + $updater->hooks(); + self::assertSame([1], WawpTestState::$priorities['upgrader_pre_download']); + $result = $updater->filter_pre_download('/tmp/autre.zip', 'https://updates.wearewp.pro/v1/download/x', null, ['plugin' => self::BASENAME]); + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('wawp_package_hijacked', $result->get_error_code()); + self::assertSame([], WawpTestState::$requests); + } + + /** @return array */ + public static function mismatches(): array + { + return [ + 'autre version signée (retour arrière)' => ['1.4.0', 'wawp-demo', '1.2.0', '1.3.0'], + 'autre slug signé (substitution)' => ['1.4.0', 'wawp-autre', '1.4.0', '1.3.0'], + 'version égale à l\'installée' => ['1.4.0', 'wawp-demo', '1.4.0', '1.4.0'], + 'version inférieure à l\'installée' => ['1.4.0', 'wawp-demo', '1.4.0', '1.5.0'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('mismatches')] + public function testPreDownloadRefusesSignedPackageThatDoesNotMatchTheOffer(string $offer, string $slug, string $version, string $installed): void + { + WawpTestState::$versions[self::FILE] = $installed; + $this->serve($offer, $slug, $version); + $result = $this->updater()->filter_pre_download(false, 'https://updates.wearewp.pro/v1/download/x', null, ['plugin' => self::BASENAME]); + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('wawp_package_mismatch', $result->get_error_code()); + self::assertFileDoesNotExist($this->zip . '.dl'); + } + public function testPreDownloadReportsDownloadFailureWithoutLeakingTheToken(): void { WawpTestState::$download = static fn () => new WP_Error('http_404', 'https://updates.wearewp.pro/v1/download/secret'); diff --git a/wawp-updater/src/class-updater.php b/wawp-updater/src/class-updater.php index 2d1ed5f..53e8b64 100644 --- a/wawp-updater/src/class-updater.php +++ b/wawp-updater/src/class-updater.php @@ -62,7 +62,7 @@ public function __construct( array $plugin_files, Client $client, array $public_ public function hooks(): void { add_filter( 'update_plugins_' . Config::HOST, array( $this, 'filter_update' ), 10, 4 ); add_filter( 'plugins_api', array( $this, 'filter_plugins_api' ), 10, 3 ); - add_filter( 'upgrader_pre_download', array( $this, 'filter_pre_download' ), 10, 4 ); + add_filter( 'upgrader_pre_download', array( $this, 'filter_pre_download' ), 1, 4 ); add_action( 'upgrader_process_complete', array( $this, 'flush_cache' ), 10, 0 ); add_action( 'load-update-core.php', array( $this, 'maybe_flush_on_force_check' ), 9 ); add_action( 'admin_notices', array( $this, 'admin_notice' ) ); @@ -88,7 +88,18 @@ public function filter_update( $update, $plugin_data, $plugin_file, $locales ) { } $offers = $this->offers( false ); if ( ! isset( $offers[ $slug ] ) ) { - return $update; + $installed = $this->installed(); + if ( null === $this->key() || ! isset( $installed[ $slug ] ) ) { + return $update; + } + + // Plugin à jour : WordPress le range dans no_update (bascule des mises à jour automatiques). + return array( + 'slug' => $slug, + 'version' => $installed[ $slug ], + 'url' => Config::ENDPOINT, + 'package' => '', + ); } $offer = $offers[ $slug ]; @@ -171,9 +182,13 @@ public function filter_plugins_api( $result, $action, $args ) { * @return mixed Chemin du fichier vérifié, WP_Error, ou la valeur reçue. */ public function filter_pre_download( $reply, $package, $upgrader, $hook_extra ) { - if ( false !== $reply || ! is_string( $package ) || 0 !== strpos( $package, Config::ENDPOINT . '/v1/download/' ) ) { + if ( ! is_string( $package ) || 0 !== strpos( $package, Config::ENDPOINT . '/v1/download/' ) ) { return $reply; } + if ( false !== $reply ) { + // Enregistré en priorité 1 : un autre filtre qui a déjà fourni un fichier court-circuiterait la signature. + return new \WP_Error( 'wawp_package_hijacked', __( 'Paquet WeAre[WP] déjà pris en charge par une autre extension : mise à jour annulée.', 'wawp-updater' ) ); + } try { $plugin = is_array( $hook_extra ) && isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ? $hook_extra['plugin'] : ''; $slug = $this->basenames[ $plugin ] ?? null; @@ -192,6 +207,13 @@ public function filter_pre_download( $reply, $package, $upgrader, $hook_extra ) wp_delete_file( $file ); return new \WP_Error( 'wawp_signature_invalid', __( 'Signature du paquet WeAre[WP] invalide : mise à jour annulée.', 'wawp-updater' ) ); } + // Paquet signé mais différent de l'offre (retour arrière ou substitution par un serveur compromis). + $version = $offers[ $slug ]['version']; + $installed = $this->installed(); + if ( ! isset( $installed[ $slug ] ) || ! version_compare( $version, $installed[ $slug ], '>' ) || ! Package::matches( $file, $slug, $version ) ) { + wp_delete_file( $file ); + return new \WP_Error( 'wawp_package_mismatch', __( 'Paquet WeAre[WP] non conforme à la mise à jour proposée : mise à jour annulée.', 'wawp-updater' ) ); + } return $file; } catch ( \Throwable $e ) { From 515be3928b5b6217968e51357df3ed0bc6d01af5 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 14:43:59 +0200 Subject: [PATCH 17/23] =?UTF-8?q?Envoi=20FTPS=20:=20lftp=20s'arr=C3=AAte?= =?UTF-8?q?=20=C3=A0=20la=20premi=C3=A8re=20erreur=20et=20nom=20du=20paque?= =?UTF-8?q?t=20valid=C3=A9=20avant=20tout=20appel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/upload-ftps.sh | 11 +++++++++-- tests/Unit/WorkflowPolicyTest.php | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/scripts/upload-ftps.sh b/scripts/upload-ftps.sh index 7ee5984..2954967 100644 --- a/scripts/upload-ftps.sh +++ b/scripts/upload-ftps.sh @@ -5,6 +5,14 @@ set -euo pipefail zip="${1:-}" sig="$zip.sig" +name="$(basename -- "${zip:-invalide}")" + +# Nom contrôlé avant tout appel à lftp : il est interpolé dans la commande lftp. +pattern='^[a-z0-9-]{1,64}-[0-9]{1,5}(\.[0-9]{1,5}){2}\.zip$' +if [[ ! "$name" =~ $pattern ]]; then + echo "Nom de paquet invalide : $name (attendu -X.Y.Z.zip)" >&2 + exit 1 +fi for variable in FTP_HOST FTP_USER LFTP_PASSWORD; do if [ -z "${!variable:-}" ]; then echo "Variable manquante : $variable" >&2 @@ -16,8 +24,7 @@ if [ ! -f "$zip" ] || [ ! -f "$sig" ]; then exit 1 fi -name="$(basename "$zip")" -lftp -c "set ftp:ssl-force true; set ftp:ssl-protect-data true; set ssl:verify-certificate true; set net:max-retries 2; set net:timeout 30; \ +lftp -c "set cmd:fail-exit true; set ftp:ssl-force true; set ftp:ssl-protect-data true; set ssl:verify-certificate true; set net:max-retries 2; set net:timeout 30; \ open --env-password -u \"$FTP_USER\" \"ftp://$FTP_HOST\"; \ put \"$zip\" -o \"$name.part\"; mv \"$name.part\" \"$name\"; \ put \"$sig\" -o \"$name.sig.part\"; mv \"$name.sig.part\" \"$name.sig\"" diff --git a/tests/Unit/WorkflowPolicyTest.php b/tests/Unit/WorkflowPolicyTest.php index f4b7fd9..543fa66 100644 --- a/tests/Unit/WorkflowPolicyTest.php +++ b/tests/Unit/WorkflowPolicyTest.php @@ -86,7 +86,23 @@ public function testUploadScriptForcesTlsAndUsesPartFiles(): void self::assertStringContainsString($needle, $script); } self::assertLessThan((int) strpos($script, '$name.sig.part'), (int) strpos($script, '"$name.part"'), 'zip avant signature'); + self::assertStringContainsString('lftp -c "set cmd:fail-exit true;', $script, 'lftp s\'arrête à la première commande en échec'); [$code, , $err] = Cli::run(['bash', '-n', 'scripts/upload-ftps.sh']); self::assertSame(0, $code, $err); } + + public function testUploadScriptValidatesThePackageNameBeforeLftp(): void + { + $script = self::file('scripts/upload-ftps.sh'); + self::assertLessThan((int) strpos($script, 'lftp -c'), (int) strpos($script, 'Nom de paquet invalide')); + foreach (['dist/Wawp-Demo-1.4.0.zip', 'dist/wawp-demo-1.4.zip', 'dist/wawp-demo-1.4.0.1.zip', 'dist/wawp demo-1.4.0.zip', 'dist/wawp-demo-1.4.0.zip;rm', 'dist/wawp-demo-123456.0.0.zip', ''] as $zip) { + [$code, , $err] = Cli::run(['bash', 'scripts/upload-ftps.sh', $zip]); + self::assertSame(1, $code, $zip); + self::assertStringContainsString('Nom de paquet invalide', $err, $zip); + } + // Nom valide : le contrôle suivant (variables, ou paquet absent) prend le relais. + [$code, , $err] = Cli::run(['bash', 'scripts/upload-ftps.sh', 'dist/wawp-demo-1.4.0.zip']); + self::assertSame(1, $code); + self::assertStringNotContainsString('Nom de paquet invalide', $err); + } } From f595ca74b61a79a803c78b653e7292fda1484a45 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 14:47:15 +0200 Subject: [PATCH 18/23] =?UTF-8?q?Cha=C3=AEne=20de=20publication=20:=20cont?= =?UTF-8?q?r=C3=B4le=20du=20paquet=20construit,=20version=20minimale=20lue?= =?UTF-8?q?=20aussi=20sur=20main,=20durcissements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/check-package.php vérifie build// avant le zip (wawp-updater complet, chargement et inscription, en-têtes Version et Update URI, deux clés publiques distinctes, aucun dossier .git) ; - le job build lit config/min-updater-version.txt sur main (checkout épinglé, sparse) et retient la plus haute ; - check-headers.php refuse une version minimale malformée ou un dossier vide ; - lftp installé dans une étape sans secrets, overwrite sur les artefacts, Release jamais écrasée. --- .github/workflows/plugin-release.yml | 40 ++++++++-- scripts/check-headers.php | 8 ++ scripts/check-package.php | 105 ++++++++++++++++++++++++++ tests/Unit/CheckHeadersTest.php | 12 +++ tests/Unit/CheckPackageTest.php | 106 +++++++++++++++++++++++++++ tests/Unit/WorkflowPolicyTest.php | 63 ++++++++++++++++ 6 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 scripts/check-package.php create mode 100644 tests/Unit/CheckPackageTest.php diff --git a/.github/workflows/plugin-release.yml b/.github/workflows/plugin-release.yml index 31b4de1..6fddfd8 100644 --- a/.github/workflows/plugin-release.yml +++ b/.github/workflows/plugin-release.yml @@ -37,6 +37,16 @@ jobs: path: tools persist-credentials: false + # Un relèvement de la version minimale sur main s'applique même aux appels épinglés sur un ancien tag. + - name: Version minimale de wawp-updater exigée sur main + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: wearewp/release-tools + ref: main + path: tools-main + sparse-checkout: config + persist-credentials: false + - name: Slug et version id: meta run: | @@ -46,7 +56,11 @@ jobs: - name: Contrôles avant publication env: SLUG: ${{ steps.meta.outputs.slug }} - run: php tools/scripts/check-headers.php plugin "$SLUG" "$GITHUB_REF_NAME" "$(cat tools/config/min-updater-version.txt)" + run: | + ref_min="$(cat tools/config/min-updater-version.txt)" + main_min="$(cat tools-main/config/min-updater-version.txt)" + min="$(printf '%s\n%s\n' "$ref_min" "$main_min" | sort -V | tail -n 1)" + php tools/scripts/check-headers.php plugin "$SLUG" "$GITHUB_REF_NAME" "$min" - name: Syntaxe PHP run: find plugin -name '*.php' -not -path '*/node_modules/*' -print0 | xargs -0 -n1 php -l > /dev/null @@ -68,6 +82,7 @@ jobs: run: | mkdir -p "build/$SLUG" dist rsync -a --exclude-from=plugin/.distignore --exclude=.git --exclude=.github plugin/ "build/$SLUG/" + php tools/scripts/check-package.php "build/$SLUG" "$SLUG" "$VERSION" (cd build && zip -rqX "../dist/$SLUG-$VERSION.zip" "$SLUG") - name: Conservation du paquet @@ -77,6 +92,7 @@ jobs: path: dist/ retention-days: 1 if-no-files-found: error + overwrite: true sign-upload: name: Signature et envoi @@ -103,6 +119,11 @@ jobs: name: paquet path: dist + - name: Installation de lftp + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq lftp > /dev/null + - name: Signature env: SIGNING_KEY: ${{ secrets.SIGNING_KEY }} @@ -115,10 +136,7 @@ jobs: FTP_USER: ${{ secrets.FTP_USER }} LFTP_PASSWORD: ${{ secrets.FTP_PASSWORD }} ZIP: dist/${{ needs.build.outputs.slug }}-${{ needs.build.outputs.version }}.zip - run: | - sudo apt-get update -qq - sudo apt-get install -y -qq lftp > /dev/null - bash tools/scripts/upload-ftps.sh "$ZIP" + run: bash tools/scripts/upload-ftps.sh "$ZIP" - name: Conservation de la signature uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -127,6 +145,7 @@ jobs: path: dist/*.sig retention-days: 1 if-no-files-found: error + overwrite: true publish-release: name: Archive dans la Release GitHub @@ -153,7 +172,16 @@ jobs: GH_TOKEN: ${{ github.token }} run: | if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then - gh release upload "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --clobber + assets="$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name')" + for file in dist/*; do + while IFS= read -r asset; do + if [ "$asset" = "$(basename "$file")" ]; then + echo "La Release $GITHUB_REF_NAME contient déjà $asset : aucun remplacement automatique. Vérifier l'asset existant et le supprimer à la main s'il doit être remplacé, puis relancer le job." >&2 + exit 1 + fi + done <<< "$assets" + done + gh release upload "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" else gh release create "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --title "$GITHUB_REF_NAME" --notes "Paquet signé publié sur updates.wearewp.pro." fi diff --git a/scripts/check-headers.php b/scripts/check-headers.php index 11eb996..380bcee 100644 --- a/scripts/check-headers.php +++ b/scripts/check-headers.php @@ -35,6 +35,14 @@ return $value === '' ? null : $value; }; +if ($dir === '' || !is_dir($dir)) { + fwrite(STDERR, "Dossier du plugin introuvable : « $dir ».\n"); + exit(1); +} +if (preg_match('/^\d+\.\d+\.\d+\z/', $minUpdater) !== 1) { + fwrite(STDERR, "Version minimale de wawp-updater invalide : « $minUpdater » (format attendu X.Y.Z, config/min-updater-version.txt).\n"); + exit(1); +} if (preg_match('/^[a-z0-9-]{1,64}\z/', $slug) !== 1) { $errors[] = "Slug invalide : « $slug » (le nom du dépôt doit être le slug du plugin, en minuscules)."; } diff --git a/scripts/check-package.php b/scripts/check-package.php new file mode 100644 index 0000000..4cf49a1 --- /dev/null +++ b/scripts/check-package.php @@ -0,0 +1,105 @@ +)> + */ + +declare(strict_types=1); + +if (PHP_SAPI !== 'cli') { + exit(1); +} + +$dir = rtrim($argv[1] ?? '', '/\\'); +$slug = $argv[2] ?? ''; +$version = $argv[3] ?? ''; +$errors = []; + +$read = static function (string $path, int $limit = 0): ?string { + if (!is_file($path)) { + return null; + } + $content = $limit > 0 ? file_get_contents($path, false, null, 0, $limit) : file_get_contents($path); + + return $content === false ? null : str_replace("\r", "\n", $content); +}; +// Même extraction d'en-tête que get_file_data() de WordPress. +$header = static function (string $content, string $name): ?string { + $pattern = '/^(?:[ \t]*<\?php)?[ \t\/*#@]*' . preg_quote($name, '/') . ':(.*)$/mi'; + if (preg_match($pattern, $content, $m) !== 1) { + return null; + } + $value = trim((string) preg_replace('/\s*(?:\*\/|\?>).*/', '', $m[1])); + + return $value === '' ? null : $value; +}; + +if ($dir === '' || !is_dir($dir)) { + fwrite(STDERR, "Dossier du paquet construit introuvable : « $dir ».\n"); + exit(1); +} +if (preg_match('/^[a-z0-9-]{1,64}\z/', $slug) !== 1) { + $errors[] = "Slug invalide : « $slug »."; +} +if (preg_match('/^\d{1,5}\.\d{1,5}\.\d{1,5}\z/', $version) !== 1) { + $errors[] = "Version invalide : « $version » (format attendu X.Y.Z)."; +} + +$required = ['wawp-updater/load.php', 'wawp-updater/bootstrap.php']; +foreach (['config', 'validator', 'signature', 'client', 'updater', 'package'] as $class) { + $required[] = "wawp-updater/src/class-$class.php"; +} +foreach ($required as $file) { + if (!is_file("$dir/$file")) { + $errors[] = "$file absent du paquet."; + } +} + +$main = $read("$dir/$slug.php"); +if ($main === null) { + $errors[] = "Fichier principal $slug.php absent du paquet."; +} else { + if (strpos($main, 'wawp-updater/load.php') === false) { + $errors[] = "$slug.php ne charge pas wawp-updater/load.php."; + } + if (strpos($main, '\WAWP\Updater\register(') === false) { + $errors[] = "$slug.php n'appelle pas \\WAWP\\Updater\\register(."; + } + $head = substr($main, 0, 8192); + $declared = $header($head, 'Version'); + if ($declared !== $version) { + $errors[] = 'En-tête Version (' . ($declared ?? 'absent') . ") différent de la version publiée ($version)."; + } + if ($header($head, 'Update URI') !== 'https://updates.wearewp.pro') { + $errors[] = 'En-tête Update URI absent ou différent de https://updates.wearewp.pro.'; + } +} + +$config = $read("$dir/wawp-updater/src/class-config.php"); +$keys = []; +if ($config !== null && preg_match('/const PUBLIC_KEYS = array\(([^)]*)\);/', $config, $m) === 1) { + preg_match_all("/'([A-Za-z0-9+\\/=]+)'/", $m[1], $found); + $keys = $found[1]; +} +$validKeys = array_filter($keys, static function (string $key): bool { + $raw = base64_decode($key, true); + + return $raw !== false && strlen($raw) === 32; +}); +if (count($keys) !== 2 || count($validKeys) !== 2 || count(array_unique($validKeys)) !== 2) { + $errors[] = 'PUBLIC_KEYS doit contenir exactement deux clés publiques Ed25519 distinctes (wawp-updater/src/class-config.php).'; +} + +$entries = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST); +foreach ($entries as $entry) { + if ($entry instanceof SplFileInfo && $entry->getFilename() === '.git') { + $errors[] = 'Dossier .git présent dans le paquet : ' . substr(str_replace('\\', '/', $entry->getPathname()), strlen($dir) + 1) . '.'; + } +} + +if ($errors !== []) { + fwrite(STDERR, "Paquet non conforme :\n- " . implode("\n- ", $errors) . "\n"); + exit(1); +} + +echo "Paquet conforme : $slug $version\n"; diff --git a/tests/Unit/CheckHeadersTest.php b/tests/Unit/CheckHeadersTest.php index a5c1a91..a05272f 100644 --- a/tests/Unit/CheckHeadersTest.php +++ b/tests/Unit/CheckHeadersTest.php @@ -47,6 +47,18 @@ private function check(string $slug = 'wawp-demo', string $tag = 'v1.4.0', strin return [$code, $out . $err]; } + public function testMalformedMinimumUpdaterVersionOrEmptyDirectoryIsRefused(): void + { + foreach (['', '1.0', 'v1.0.0', '1.0.0-beta', "1.0.0\n2.0.0"] as $min) { + [$code, $out] = $this->check('wawp-demo', 'v1.4.0', $min); + self::assertSame(1, $code, var_export($min, true)); + self::assertStringContainsString('Version minimale de wawp-updater invalide', $out, var_export($min, true)); + } + [$code, $out, $err] = Cli::run([PHP_BINARY, Cli::php('scripts/check-headers.php'), '', 'wawp-demo', 'v1.4.0', '1.0.0']); + self::assertSame(1, $code); + self::assertStringContainsString('Dossier du plugin', $out . $err); + } + public function testValidPluginPasses(): void { [$code, $out] = $this->check(); diff --git a/tests/Unit/CheckPackageTest.php b/tests/Unit/CheckPackageTest.php new file mode 100644 index 0000000..12de841 --- /dev/null +++ b/tests/Unit/CheckPackageTest.php @@ -0,0 +1,106 @@ +dir = sys_get_temp_dir() . '/wawp-pkg-' . bin2hex(random_bytes(4)) . '/build/wawp-demo'; + mkdir($this->dir . '/wawp-updater/src', 0777, true); + $this->keys = [Keys::pair()['public'], Keys::pair()['public']]; + $this->write('wawp-demo.php', self::main()); + $this->write('readme.txt', "Stable tag: 1.4.0\n"); + $this->write('wawp-updater/load.php', "write('wawp-updater/bootstrap.php', "write("wawp-updater/src/class-$class.php", "config($this->keys); + } + + protected function tearDown(): void + { + $root = dirname($this->dir, 2); + $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($it as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($root); + } + + private static function main(string $version = '1.4.0', string $uri = 'https://updates.wearewp.pro', string $load = "require __DIR__ . '/wawp-updater/load.php';", string $register = '\WAWP\Updater\register( __FILE__ );'): string + { + return "dir . '/' . $path, $content); + } + + /** @param list $keys */ + private function config(array $keys): void + { + $list = implode(', ', array_map(static fn (string $key): string => "'$key'", $keys)); + $this->write('wawp-updater/src/class-config.php', "dir, $slug, $version]); + + return [$code, $out . $err]; + } + + public function testValidPackagePasses(): void + { + [$code, $out] = $this->check(); + self::assertSame(0, $code, $out); + self::assertStringContainsString('Paquet conforme : wawp-demo 1.4.0', $out); + } + + public function testEachDefectIsReported(): void + { + $cases = [ + 'load.php absent' => [fn () => unlink($this->dir . '/wawp-updater/load.php'), null, 'wawp-updater/load.php'], + 'bootstrap.php absent' => [fn () => unlink($this->dir . '/wawp-updater/bootstrap.php'), null, 'wawp-updater/bootstrap.php'], + 'fichier principal absent' => [fn () => unlink($this->dir . '/wawp-demo.php'), null, 'wawp-demo.php absent'], + 'chargement absent' => [fn () => $this->write('wawp-demo.php', self::main(load: '')), null, 'wawp-updater/load.php'], + 'inscription absente' => [fn () => $this->write('wawp-demo.php', self::main(register: '')), null, 'register('], + 'version' => [fn () => $this->write('wawp-demo.php', self::main(version: '1.3.9')), null, 'En-tête Version'], + 'update uri différent' => [fn () => $this->write('wawp-demo.php', self::main(uri: 'https://updates.wearewp.pro/')), null, 'Update URI'], + 'une seule clé' => [fn () => $this->config([$this->keys[0]]), null, 'PUBLIC_KEYS'], + 'clés identiques' => [fn () => $this->config([$this->keys[0], $this->keys[0]]), null, 'PUBLIC_KEYS'], + 'clé invalide' => [fn () => $this->config([$this->keys[0], base64_encode('trop court')]), null, 'PUBLIC_KEYS'], + 'dossier .git' => [fn () => mkdir($this->dir . '/vendor/lib/.git', 0777, true), null, '.git'], + 'slug invalide' => [fn () => null, [null, 'Wawp_Demo', '1.4.0'], 'Slug invalide'], + 'version invalide' => [fn () => null, [null, 'wawp-demo', '1.4'], 'Version invalide'], + 'dossier vide' => [fn () => null, ['', 'wawp-demo', '1.4.0'], 'Dossier'], + ]; + foreach (self::CLASSES as $class) { + $cases["class-$class.php absent"] = [fn () => unlink($this->dir . "/wawp-updater/src/class-$class.php"), null, "src/class-$class.php"]; + } + foreach ($cases as $label => [$mutate, $args, $expected]) { + $this->tearDown(); + $this->setUp(); + $mutate(); + [$code, $out] = $args === null ? $this->check() : $this->check(...$args); + self::assertSame(1, $code, "$label : $out"); + self::assertStringContainsString($expected, $out, $label); + } + } +} diff --git a/tests/Unit/WorkflowPolicyTest.php b/tests/Unit/WorkflowPolicyTest.php index 543fa66..bfaf135 100644 --- a/tests/Unit/WorkflowPolicyTest.php +++ b/tests/Unit/WorkflowPolicyTest.php @@ -70,6 +70,69 @@ public function testJobSeparationAndPermissions(): void self::assertStringNotContainsString('actions/checkout@', $jobs['publish-release'], 'aucun clone dans le job qui écrit'); } + /** @return array étapes d'un job indexées par nom */ + private static function steps(string $job): array + { + $parts = preg_split('/^ - name: (.+)\n/m', $job, -1, PREG_SPLIT_DELIM_CAPTURE) ?: []; + $steps = []; + for ($i = 1; $i + 1 < count($parts); $i += 2) { + $steps[$parts[$i]] = $parts[$i + 1]; + } + + return $steps; + } + + public function testBuildChecksThePackageAndTakesTheHighestMinimumUpdaterVersion(): void + { + $build = self::jobs(self::file('.github/workflows/plugin-release.yml'))['build']; + $steps = self::steps($build); + + $main = $steps['Version minimale de wawp-updater exigée sur main'] ?? ''; + foreach (['uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683', 'repository: wearewp/release-tools', 'ref: main', 'path: tools-main', 'sparse-checkout: config', 'persist-credentials: false'] as $needle) { + self::assertStringContainsString($needle, $main, $needle); + } + $checks = $steps['Contrôles avant publication'] ?? ''; + self::assertStringContainsString('tools/config/min-updater-version.txt', $checks); + self::assertStringContainsString('tools-main/config/min-updater-version.txt', $checks); + self::assertStringContainsString('sort -V | tail -n 1', $checks); + self::assertStringContainsString('"$min"', $checks); + + $package = $steps['Paquet'] ?? ''; + $rsync = (int) strpos($package, 'rsync '); + $check = (int) strpos($package, 'php tools/scripts/check-package.php "build/$SLUG" "$SLUG" "$VERSION"'); + $zip = (int) strpos($package, 'zip -rqX'); + self::assertGreaterThan(0, $check, 'contrôle du paquet construit'); + self::assertLessThan($check, $rsync); + self::assertLessThan($zip, $check); + } + + public function testLftpIsInstalledInAStepWithoutSecrets(): void + { + $sign = self::jobs(self::file('.github/workflows/plugin-release.yml'))['sign-upload']; + $steps = self::steps($sign); + self::assertArrayHasKey('Installation de lftp', $steps); + self::assertStringNotContainsString('env:', $steps['Installation de lftp']); + self::assertStringContainsString('apt-get install', $steps['Installation de lftp']); + $names = array_keys($steps); + self::assertLessThan(array_search('Signature', $names, true), array_search('Installation de lftp', $names, true)); + foreach ($steps as $name => $step) { + if (str_contains($step, 'secrets.')) { + self::assertStringNotContainsString('apt-get', $step, $name); + } + } + } + + public function testArtifactsAreOverwrittenAndReleaseAssetsNeverClobbered(): void + { + $yaml = self::file('.github/workflows/plugin-release.yml'); + self::assertSame(substr_count($yaml, 'actions/upload-artifact@'), substr_count($yaml, 'overwrite: true')); + self::assertSame(2, substr_count($yaml, 'overwrite: true')); + self::assertStringNotContainsString('--clobber', $yaml); + $release = self::steps(self::jobs($yaml)['publish-release'])['Release'] ?? ''; + self::assertStringContainsString('--json assets', $release); + self::assertStringContainsString('contient déjà', $release); + } + public function testCallerTemplate(): void { $yaml = self::file('templates/release.yml'); From a9f5aa4f0cb07e095b01e96078a560bc9b4b7e1a Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 14:47:16 +0200 Subject: [PATCH 19/23] =?UTF-8?q?setup-repo=20:=20liste=20des=20rulesets?= =?UTF-8?q?=20captur=C3=A9e=20avant=20le=20grep=20(pas=20de=20pipe=20sous?= =?UTF-8?q?=20pipefail)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/setup-repo.sh | 3 ++- tests/Unit/SetupRepoTest.php | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/bin/setup-repo.sh b/bin/setup-repo.sh index f3a34b0..4fab521 100644 --- a/bin/setup-repo.sh +++ b/bin/setup-repo.sh @@ -29,7 +29,8 @@ for secret in SIGNING_KEY FTP_HOST FTP_USER FTP_PASSWORD; do echo "Secret $secret accessible à $repo." done -if gh api "repos/$repo/rulesets" --jq '.[].name' | grep -qx 'Tags de version immuables'; then +rulesets="$(gh api "repos/$repo/rulesets" --jq '.[].name')" +if grep -qx 'Tags de version immuables' <<< "$rulesets"; then echo "Ruleset déjà présent." else gh api -X POST "repos/$repo/rulesets" --input - > /dev/null <<'JSON' diff --git a/tests/Unit/SetupRepoTest.php b/tests/Unit/SetupRepoTest.php index 5b73760..9973e22 100644 --- a/tests/Unit/SetupRepoTest.php +++ b/tests/Unit/SetupRepoTest.php @@ -41,6 +41,15 @@ public function testRulesetAndGuards(): void } } + public function testRulesetListIsCapturedBeforeGrep(): void + { + // Sous pipefail, « gh api | grep -q » peut échouer par SIGPIPE et créer un ruleset en double. + $script = self::script(); + self::assertStringContainsString('rulesets="$(gh api "repos/$repo/rulesets" --jq \'.[].name\')"', $script); + self::assertStringContainsString('grep -qx \'Tags de version immuables\' <<< "$rulesets"', $script); + self::assertDoesNotMatchRegularExpression('/\|\s*grep -q/', $script); + } + public function testRefusesBadArguments(): void { [$code, , $err] = Cli::run(['bash', self::SCRIPT, 'autre/depot', '.']); From a341d9a85707d83e259a61189e2b1c5e1c22259b Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 14:47:46 +0200 Subject: [PATCH 20/23] =?UTF-8?q?Proc=C3=A9dure=20:=20rotation=20et=20vers?= =?UTF-8?q?ion=20minimale,=20d=C3=A9lai=20de=2024=20h,=20h=C3=B4te=20FTPS,?= =?UTF-8?q?=20workflows=20r=C3=A9utilisables,=20runs=20annul=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mise-en-place.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/mise-en-place.md b/docs/mise-en-place.md index 19bd132..67fc6db 100644 --- a/docs/mise-en-place.md +++ b/docs/mise-en-place.md @@ -31,7 +31,7 @@ gh secret set FTP_PASSWORD --org wearewp --visibility selected --repos wawp-test Les secrets d'organisation doivent impérativement être créés avec `--visibility selected` : sinon `bin/setup-repo.sh` ne peut pas y ajouter de dépôts. -`FTP_HOST`, `FTP_USER`, `FTP_PASSWORD` : le compte FTP dédié créé dans cPanel, limité à `updates-data/incoming`. Les autres dépôts de plugins sont ajoutés ensuite par `bin/setup-repo.sh` (étape 6), sans jamais retaper les valeurs. +`FTP_HOST`, `FTP_USER`, `FTP_PASSWORD` : le compte FTP dédié créé dans cPanel, limité à `updates-data/incoming`. `FTP_HOST` doit être le nom d'hôte couvert par le certificat FTPS d'o2switch (celui du serveur mutualisé, affiché dans cPanel), et non `updates.wearewp.pro` ni une adresse IP : l'envoi vérifie le certificat et échoue sinon. Les autres dépôts de plugins sont ajoutés ensuite par `bin/setup-repo.sh` (étape 6), sans jamais retaper les valeurs. ## 3. Serveur o2switch @@ -45,7 +45,7 @@ Placer la ligne `const PUBLIC_KEYS = array( '…', '…' );` affichée par `keyg ## 5. Ruleset des tags de release-tools -Le tag `v1` est la référence utilisée par tous les plugins. Les commandes de cette section et de la suivante s'exécutent dans Git Bash, avec `gh` authentifié par un compte propriétaire de l'organisation `wearewp` (vérifier avec `gh auth status`). +Le tag `v1` est la référence utilisée par tous les plugins. La politique Actions de l'organisation (Settings > Actions > General) doit autoriser les workflows réutilisables de `wearewp/*`, sinon l'appel depuis les dépôts de plugins est refusé. Les commandes de cette section et de la suivante s'exécutent dans Git Bash, avec `gh` authentifié par un compte propriétaire de l'organisation `wearewp` (vérifier avec `gh auth status`). D'abord protéger les tags : @@ -88,12 +88,16 @@ git tag vX.Y.Z git push origin vX.Y.Z ``` -Le workflow contrôle, construit, signe, envoie sur o2switch et archive le paquet dans la Release GitHub. Le cron d'ingestion le publie dans les 5 minutes ; les sites le voient à leur prochaine vérification (12 h au plus, ou « Rechercher les mises à jour »). +Le workflow contrôle, construit, signe, envoie sur o2switch et archive le paquet dans la Release GitHub. Le cron d'ingestion le publie dans les 5 minutes ; sans action de l'administrateur, un site peut mettre jusqu'à environ 24 h à la voir (cache de WordPress de 12 h, puis cache du client de 12 h) ; « Tableau de bord > Mises à jour > Rechercher les mises à jour » vide les deux caches et la fait apparaître aussitôt. + +Si plusieurs tags sont poussés coup sur coup, la règle de concurrence du job d'envoi peut annuler un run en attente (seul le plus récent attend son tour). Vérifier l'onglet Actions : relancer le run annulé (« Re-run jobs ») ou repousser le tag concerné. ## 8. Rotation de la clé de signature (fuite de la clé active) Suivre la section 11 de la spec (`docs/superpowers/specs/2026-10-12-serveur-mises-a-jour-design.md` dans le dépôt updates-server) : passer `SIGNING_KEY` sur la clé de secours avec `gh secret set SIGNING_KEY --org wearewp --visibility selected --repos ` (attention : `--repos` remplace la liste existante, y inclure tous les dépôts de plugins), générer une nouvelle paire de secours, publier une version de `wawp-updater` dont `PUBLIC_KEYS` vaut `array( ancienne clé de secours, nouvelle clé de secours )`, l'intégrer dans tous les plugins et les publier, puis retirer l'ancienne clé publique de `signing.pub`. +La version de rotation de `wawp-updater` incrémente `$wawp_updater_version` dans `load.php` et `Config::VERSION` dans `src/class-config.php`, toujours égales (par exemple `1.0.0` vers `1.1.0`) : c'est la copie la plus récente qui est chargée sur un site. Une fois cette version taguée, relever `config/min-updater-version.txt` sur la branche `main` de release-tools à cette même version : chaque publication lit aussi ce fichier sur `main` et retient la plus haute des deux valeurs, ce qui bloque la publication d'un plugin qui embarque encore l'ancien client, même si son workflow est épinglé sur un ancien tag. + ## Limite connue WordPress n'appelle les filtres `Update URI` qu'après une réponse réussie d'`api.wordpress.org`. Si ce service est injoignable depuis un site, les mises à jour WeAre[WP] n'y apparaissent pas tant que la situation dure ; elles réapparaissent dès le contrôle suivant. From 7ae8a11704f7ede0a999de062019189500ac9057 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 14:50:40 +0200 Subject: [PATCH 21/23] =?UTF-8?q?Client=20:=20refus=20du=20paquet=20d?= =?UTF-8?q?=C3=A9tourn=C3=A9=20=C3=A0=20l'int=C3=A9rieur=20du=20try/catch?= =?UTF-8?q?=20du=20filtre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- wawp-updater/src/class-updater.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/wawp-updater/src/class-updater.php b/wawp-updater/src/class-updater.php index 53e8b64..c635cbf 100644 --- a/wawp-updater/src/class-updater.php +++ b/wawp-updater/src/class-updater.php @@ -185,11 +185,11 @@ public function filter_pre_download( $reply, $package, $upgrader, $hook_extra ) if ( ! is_string( $package ) || 0 !== strpos( $package, Config::ENDPOINT . '/v1/download/' ) ) { return $reply; } - if ( false !== $reply ) { - // Enregistré en priorité 1 : un autre filtre qui a déjà fourni un fichier court-circuiterait la signature. - return new \WP_Error( 'wawp_package_hijacked', __( 'Paquet WeAre[WP] déjà pris en charge par une autre extension : mise à jour annulée.', 'wawp-updater' ) ); - } try { + if ( false !== $reply ) { + // Enregistré en priorité 1 : un autre filtre qui a déjà fourni un fichier court-circuiterait la signature. + return new \WP_Error( 'wawp_package_hijacked', __( 'Paquet WeAre[WP] déjà pris en charge par une autre extension : mise à jour annulée.', 'wawp-updater' ) ); + } $plugin = is_array( $hook_extra ) && isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ? $hook_extra['plugin'] : ''; $slug = $this->basenames[ $plugin ] ?? null; if ( null === $slug ) { From 528be0aa6506389b120b308705a9770a1d5d20f6 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 14:50:41 +0200 Subject: [PATCH 22/23] =?UTF-8?q?E2E=20:=20plugin=20=C3=A0=20jour=20rang?= =?UTF-8?q?=C3=A9=20dans=20no=5Fupdate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/e2e/run.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/e2e/run.php b/tests/e2e/run.php index 2cf974b..1c7e7c9 100644 --- a/tests/e2e/run.php +++ b/tests/e2e/run.php @@ -296,6 +296,11 @@ [$leakCode, $leak] = $wp('eval', 'global $wpdb; echo (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->options WHERE option_value LIKE %s", "%" . WAWP_LICENSE_KEY . "%" ) ), "|", (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->options WHERE option_name LIKE %s", "%wawp_updater_check%" ) );'); $check('clé absente de la base (contrôle positif : cache présent)', $leakCode === 0 && preg_match('/^0\|([1-9]\d*)$/', trim($leak)) === 1, $leak); + // Plugin à jour : entrée no_update (bascule des mises à jour automatiques), aucune offre. + $flush(); + [$noUpdateCode, $noUpdate] = $wp('eval', 'wp_update_plugins(); $t = get_site_transient( "update_plugins" ); echo isset( $t->response["' . $basename . '"] ) ? "offre" : "", isset( $t->no_update["' . $basename . '"] ) ? $t->no_update["' . $basename . '"]->new_version . "|" . $t->no_update["' . $basename . '"]->package : "absent";'); + $check('plugin à jour rangé dans no_update', $noUpdateCode === 0 && trim($noUpdate) === '1.1.0|', $noUpdate); + // b. Jeton périmé en cache : le téléchargement obtient un jeton frais. $check('publication 1.2.0', $publish('1.2.0')); $flush(); From c06e982e211f14f724b8d9c5730f1d7463f4c9e8 Mon Sep 17 00:00:00 2001 From: thierrypigot Date: Mon, 12 Oct 2026 17:01:04 +0200 Subject: [PATCH 23/23] =?UTF-8?q?wawp-updater=20:=20cl=C3=A9s=20publiques?= =?UTF-8?q?=20de=20signature=20(active=20et=20secours)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- wawp-updater/src/class-config.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wawp-updater/src/class-config.php b/wawp-updater/src/class-config.php index 3b5f769..53b4141 100644 --- a/wawp-updater/src/class-config.php +++ b/wawp-updater/src/class-config.php @@ -29,7 +29,7 @@ final class Config { * * @var list */ - const PUBLIC_KEYS = array(); + const PUBLIC_KEYS = array( 'LHRLyVYY0wTP/uuvzX/VVI0KVdfncIXAnZ/UIPu/xN4=', 'fW/d3eKzXpvuKKeOQEC/RevDVr91YOmgBRXlGVh8e50=' ); /** Cache de la réponse /v1/check (transient de site). */ const CACHE_KEY = 'wawp_updater_check';