diff --git a/.github/scripts/validate-release.py b/.github/scripts/validate-release.py new file mode 100644 index 0000000..d2f0ea7 --- /dev/null +++ b/.github/scripts/validate-release.py @@ -0,0 +1,18 @@ +"""Validate a component release tag against its Cargo manifest.""" +import os +import sys +import tomllib +from pathlib import Path + +component = sys.argv[1] +if component not in {"probe", "website", "reporter"}: + raise SystemExit(f"Unknown release component: {component}") +with Path(component, "Cargo.toml").open("rb") as manifest: + version = tomllib.load(manifest)["package"]["version"] +if os.environ.get("GITHUB_REF_TYPE") == "tag": + expected = f"{component}-v{version}" + actual = os.environ["GITHUB_REF_NAME"] + if actual != expected: + raise SystemExit(f"Tag {actual!r} does not match {component}/Cargo.toml; expected {expected!r}") +with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"version={version}\n") diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2753baf..44f3998 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,81 +1,100 @@ -name: Build Rust Application - +name: Build Reporter + on: push: branches: [ master ] - tags: [ 'v*.*.*' ] + tags: [ 'reporter-v*.*.*' ] pull_request: workflow_dispatch: - -env: - CARGO_TERM_COLOR: always - -jobs: - linux-amd64: - name: Linux amd64 (.deb) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install cargo-deb - run: cargo install cargo-deb - - - name: Build Debian package - run: cargo deb -p reporter -- --bin cheburchecker - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: linux-amd64-deb - path: target/debian/*.deb - compression-level: 0 - - linux-arm64: - name: Linux arm64 (.deb via cross) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install cargo-deb - run: cargo install cargo-deb - - - name: Build binary - uses: houseabsolute/actions-rust-cross@v1 - with: - command: build - target: "aarch64-unknown-linux-gnu" - args: "--release --bin cheburchecker" - strip: true - - - name: Create Debian archive - run: cargo deb --target aarch64-unknown-linux-gnu --no-strip --no-build -p reporter -- --bin cheburchecker - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: linux-arm64-deb - path: target/aarch64-unknown-linux-gnu/debian/*.deb - - windows: - name: Windows amd64 - runs-on: windows-latest - steps: - - uses: actions/checkout@v6 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Build release binary - run: cargo build --release --bin cheburchecker - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: windows-amd64-exe - path: target/release/*.exe + +env: + CARGO_TERM_COLOR: always + +jobs: + validate: + uses: ./.github/workflows/validate-release.yml + with: + component: reporter + + linux-amd64: + needs: validate + name: Linux amd64 (.deb) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-deb + run: cargo install cargo-deb + + - name: Build Debian package + run: cargo deb -p reporter -- --bin cheburchecker + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: linux-amd64-deb + path: target/debian/*.deb + compression-level: 0 + + linux-arm64: + needs: validate + name: Linux arm64 (.deb via cross) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-deb + run: cargo install cargo-deb + + - name: Build binary + uses: houseabsolute/actions-rust-cross@v1 + with: + command: build + target: "aarch64-unknown-linux-gnu" + args: "--release --bin cheburchecker" + strip: true + + - name: Create Debian archive + run: cargo deb --target aarch64-unknown-linux-gnu --no-strip --no-build -p reporter -- --bin cheburchecker + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: linux-arm64-deb + path: target/aarch64-unknown-linux-gnu/debian/*.deb + + windows: + needs: validate + name: Windows amd64 + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build release binary + run: cargo build --release --bin cheburchecker + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: windows-amd64-exe + path: target/release/*.exe + + release: + needs: [linux-amd64, linux-arm64, windows] + if: github.event_name == 'push' && github.ref_type == 'tag' + permissions: + contents: write + actions: read + uses: ./.github/workflows/release.yml + with: + component: reporter + artifact-pattern: '*' diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index edba3f3..7bdcd8f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -3,7 +3,7 @@ name: Build and Publish Docker Images on: push: branches: [ "master" ] - tags: [ 'v*.*.*' ] + tags: [ 'website-v*.*.*' ] pull_request: branches: [ "master" ] @@ -12,7 +12,13 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: + validate: + uses: ./.github/workflows/validate-release.yml + with: + component: website + build: + needs: validate name: Build ${{ matrix.service }} image runs-on: ubuntu-latest strategy: @@ -54,8 +60,8 @@ jobs: tags: | type=ref,event=branch type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{version}},value=${{ needs.validate.outputs.version }},enable=${{ github.ref_type == 'tag' }} + type=semver,pattern={{major}}.{{minor}},value=${{ needs.validate.outputs.version }},enable=${{ github.ref_type == 'tag' }} type=sha type=raw,value=latest,enable={{is_default_branch}} @@ -81,3 +87,13 @@ jobs: - name: Trigger Coolify deploy run: | curl --fail --request POST '${{ secrets.COOLIFY_WEBHOOK }}' --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}' + + release: + needs: [build] + if: github.event_name == 'push' && github.ref_type == 'tag' + permissions: + contents: write + actions: read + uses: ./.github/workflows/release.yml + with: + component: website diff --git a/.github/workflows/probe-build.yml b/.github/workflows/probe-build.yml index 5a1d17a..d6f1a7f 100644 --- a/.github/workflows/probe-build.yml +++ b/.github/workflows/probe-build.yml @@ -3,7 +3,7 @@ name: Build Probe on: push: branches: [ master ] - tags: [ 'v*.*.*' ] + tags: [ 'probe-v*.*.*' ] pull_request: workflow_dispatch: @@ -14,7 +14,13 @@ env: OPENWRT_ARM64_ARCHES: aarch64_generic aarch64_cortex-a53 aarch64_cortex-a72 jobs: + validate: + uses: ./.github/workflows/validate-release.yml + with: + component: probe + linux-amd64: + needs: validate name: Linux amd64 binary and .deb runs-on: ubuntu-latest steps: @@ -58,6 +64,7 @@ jobs: compression-level: 0 linux-arm64: + needs: validate name: Linux arm64 binary, .deb, and OpenWrt packages runs-on: ubuntu-latest steps: @@ -134,6 +141,7 @@ jobs: compression-level: 0 windows-amd64: + needs: validate name: Windows amd64 binary runs-on: windows-latest steps: @@ -160,6 +168,7 @@ jobs: compression-level: 0 docker: + needs: validate name: Docker image amd64/arm64 runs-on: ubuntu-latest permissions: @@ -223,8 +232,8 @@ jobs: tags: | type=ref,event=branch type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{version}},value=${{ needs.validate.outputs.version }},enable=${{ github.ref_type == 'tag' }} + type=semver,pattern={{major}}.{{minor}},value=${{ needs.validate.outputs.version }},enable=${{ github.ref_type == 'tag' }} type=sha type=raw,value=latest,enable={{is_default_branch}} @@ -239,3 +248,14 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + + release: + needs: [linux-amd64, linux-arm64, windows-amd64, docker] + if: github.event_name == 'push' && github.ref_type == 'tag' + permissions: + contents: write + actions: read + uses: ./.github/workflows/release.yml + with: + component: probe + artifact-pattern: 'cheburprobe-*' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a467243..3bb434d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,15 @@ -name: Draft Release +name: Draft component release on: - push: - tags: [ 'v*.*.*' ] + workflow_call: + inputs: + component: + required: true + type: string + artifact-pattern: + required: false + type: string + default: '' permissions: actions: read @@ -17,148 +24,29 @@ jobs: name: Draft GitHub release runs-on: ubuntu-latest steps: - - name: Collect build artifacts - uses: actions/github-script@v8 - env: - TAG_NAME: ${{ github.ref_name }} - WORKFLOWS: | - Build Rust Application - Build Probe + - name: Download component artifacts + if: inputs.component != 'website' + uses: actions/download-artifact@v8 with: - script: | - const fs = require('fs'); - const path = require('path'); - - const owner = context.repo.owner; - const repo = context.repo.repo; - const tagName = process.env.TAG_NAME; - const workflows = process.env.WORKFLOWS - .split(/\r?\n/) - .map((name) => name.trim()) - .filter(Boolean); - const targetSha = context.sha; - const outDir = path.join(process.cwd(), 'artifact-archives'); - const pollDelayMs = 30_000; - const timeoutMs = 30 * 60 * 1000; - - fs.mkdirSync(outDir, { recursive: true }); - - async function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - async function findWorkflowRun(workflowName) { - const startedAt = Date.now(); - - while (Date.now() - startedAt < timeoutMs) { - const runs = await github.paginate( - github.rest.actions.listWorkflowRunsForRepo, - { - owner, - repo, - head_sha: targetSha, - event: 'push', - per_page: 100, - }, - ); - - const run = runs.find((candidate) => - candidate.name === workflowName && - candidate.head_sha === targetSha && - candidate.head_branch === tagName - ); - - if (run?.status === 'completed') { - if (run.conclusion === 'success') { - return run; - } - - throw new Error( - `${workflowName} completed with conclusion ${run.conclusion}: ${run.html_url}`, - ); - } - - core.info( - run - ? `${workflowName} is ${run.status}; waiting for completion.` - : `Waiting for ${workflowName} to start for ${tagName}.`, - ); - await sleep(pollDelayMs); - } - - throw new Error(`Timed out waiting for ${workflowName} on ${tagName}.`); - } - - for (const workflowName of workflows) { - const run = await findWorkflowRun(workflowName); - core.info(`Downloading artifacts from ${workflowName}: ${run.html_url}`); - - const artifacts = await github.paginate( - github.rest.actions.listWorkflowRunArtifacts, - { - owner, - repo, - run_id: run.id, - per_page: 100, - }, - ); - - for (const artifact of artifacts.filter((item) => !item.expired)) { - if (artifact.name.endsWith('.dockerbuild')) { - core.info(`Skipping Docker build record artifact ${artifact.name}`); - continue; - } - - const archive = await github.rest.actions.downloadArtifact({ - owner, - repo, - artifact_id: artifact.id, - archive_format: 'zip', - }); - - const artifactPath = path.join(outDir, `${artifact.name}.zip`); - fs.writeFileSync(artifactPath, Buffer.from(archive.data)); - core.info(`Saved ${artifactPath}`); - } - } - - - name: Extract release assets - shell: bash - run: | - set -euo pipefail - - mkdir -p release-artifacts - - for archive in artifact-archives/*.zip; do - artifact_name="$(basename "$archive" .zip)" - extract_dir="$(mktemp -d)" - - unzip -q "$archive" -d "$extract_dir" - - while IFS= read -r -d '' file; do - filename="$(basename "$file")" - target="release-artifacts/$filename" - - if [ -e "$target" ]; then - target="release-artifacts/${artifact_name}-${filename}" - fi - - mv "$file" "$target" - done < <(find "$extract_dir" -type f -print0) - - rm -rf "$extract_dir" - done + pattern: ${{ inputs.artifact-pattern }} + path: release-artifacts + merge-multiple: true - name: Generate release body + id: body uses: actions/github-script@v8 env: TAG_NAME: ${{ github.ref_name }} + COMPONENT: ${{ inputs.component }} with: script: | const fs = require('fs'); const path = require('path'); const tag = process.env.TAG_NAME; + fs.mkdirSync('release-artifacts', { recursive: true }); + const component = process.env.COMPONENT; + core.setOutput('prerelease', tag.slice((component + '-v').length).split('+')[0].includes('-')); const files = fs.readdirSync('release-artifacts') .filter((name) => fs.statSync(path.join('release-artifacts', name)).isFile()) .sort(); @@ -222,14 +110,14 @@ jobs: const body = [ '## Документация', '', - '- [Cheburprobe](' + sourceBaseUrl + '/probe/README.md)', - '- [Cheburchecker](' + sourceBaseUrl + '/reporter/README.md)', + '- [Документация](' + sourceBaseUrl + (component === 'website' ? '/README.md' : '/' + component + '/README.md') + ')', '', - '## Файлы релиза', + component === 'website' ? '## Docker images' : '## Файлы релиза', '', - '| Файл | Описание |', - '| --- | --- |', - ...rows, + ...(component === 'website' ? [ + '`ghcr.io/' + context.repo.owner.toLowerCase() + '/' + context.repo.repo.toLowerCase() + ':' + tag.slice('website-v'.length) + '`', + '`ghcr.io/' + context.repo.owner.toLowerCase() + '/' + context.repo.repo.toLowerCase() + '-frontend:' + tag.slice('website-v'.length) + '`', + ] : ['| Файл | Описание |', '| --- | --- |', ...rows]), '', ].join('\n'); @@ -241,6 +129,7 @@ jobs: tag_name: ${{ github.ref_name }} name: ${{ github.ref_name }} draft: true + prerelease: ${{ steps.body.outputs.prerelease }} body_path: release-body.md - fail_on_unmatched_files: true + fail_on_unmatched_files: ${{ inputs.component != 'website' }} files: release-artifacts/* diff --git a/.github/workflows/validate-release.yml b/.github/workflows/validate-release.yml new file mode 100644 index 0000000..cabfc29 --- /dev/null +++ b/.github/workflows/validate-release.yml @@ -0,0 +1,24 @@ +name: Validate component version + +on: + workflow_call: + inputs: + component: + required: true + type: string + outputs: + version: + value: ${{ jobs.validate.outputs.version }} + +jobs: + validate: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v6 + - name: Validate release tag + id: version + env: + COMPONENT: ${{ inputs.component }} + run: python3 .github/scripts/validate-release.py "$COMPONENT" diff --git a/Cargo.lock b/Cargo.lock index 9ed63f1..3804e8c 100644 Binary files a/Cargo.lock and b/Cargo.lock differ diff --git a/probe/Cargo.toml b/probe/Cargo.toml index 80c2568..e18d944 100644 --- a/probe/Cargo.toml +++ b/probe/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "probe" -version = "0.6.4" +version = "0.6.5" edition = "2024" license-file = "../LICENSE" description = "Dynamic network probe daemon for Cheburcheck" diff --git a/probe/src/sni.rs b/probe/src/sni.rs index da8204a..afe3324 100644 --- a/probe/src/sni.rs +++ b/probe/src/sni.rs @@ -1,10 +1,11 @@ use anyhow::{Result, bail}; use futures::future::join_all; -use log::warn; +use log::{debug, warn}; use reports::probe::{Host, HostProbeResult, ProbeConfig, ProbeEvidence}; use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; use rustls::{ClientConfig, DigitallySignedStruct, Error as TlsError, SignatureScheme}; +use std::io; use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -57,12 +58,16 @@ async fn probe_host(host: &Host, target: &str) -> ProbeEvidence { let server_name = match ServerName::try_from(target.to_string()) { Ok(server_name) => server_name, - Err(_) => return ProbeEvidence::ClientHello, + Err(_) => return ProbeEvidence::ConnectionError, }; let mut tls = match time::timeout(timeout, connector.connect(server_name, tcp)).await { Ok(Ok(tls)) => tls, - Ok(Err(_)) | Err(_) => return ProbeEvidence::ClientHello, + Ok(Err(e)) if e.kind() == io::ErrorKind::InvalidData => { + debug!("TLS handshake error for host {}: {:?}", host.host, e); + return ProbeEvidence::ConnectionError; + } + _ => return ProbeEvidence::ClientHello, }; let request = format!( diff --git a/website/Cargo.toml b/website/Cargo.toml index 373cfc6..129ced3 100644 --- a/website/Cargo.toml +++ b/website/Cargo.toml @@ -1,9 +1,11 @@ [package] name = "website" -version = "1.3.4" +version = "1.3.5" edition = "2024" [dependencies] +anyhow = "1.0" +semver = "1.0" rocket = { version = "0.5.1", features = ["msgpack", "json"] } rocket_dyn_templates = { version = "0.2.0", features = ["tera"] } sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres", "migrate", "chrono", "uuid"] } diff --git a/website/src/probe_updates.rs b/website/src/probe_updates.rs index 9d1dca0..0a5f441 100644 --- a/website/src/probe_updates.rs +++ b/website/src/probe_updates.rs @@ -1,4 +1,5 @@ use crate::api::ProbeUpdateDownloadRateLimiter; +use anyhow::Context; use log::error; use reqwest::Client; use rocket::State; @@ -6,6 +7,7 @@ use rocket::http::{ContentType, Status}; use rocket::serde::json::Json; use rocket::tokio::sync::Mutex; use rocket_client_addr::ClientRealAddr; +use semver::Version; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -37,9 +39,66 @@ struct CachedRelease { #[derive(Clone, Debug, Deserialize)] struct GithubRelease { + tag_name: String, + draft: bool, + prerelease: bool, assets: Vec, } +impl GithubRelease { + fn probe_version(&self) -> Option { + if self.draft || self.prerelease { + return None; + } + // Legacy v* tags used the website version. Read the probe version from + // a standalone binary instead, never compare the two version streams. + let version = + if let Some(version) = self.tag_name.strip_prefix("probe-v") { + if !self.assets.iter().any(|asset| { + valid_asset_name(&asset.name) && asset.name.starts_with("cheburprobe") + }) { + return None; + } + Version::parse(version).ok()? + } else { + let legacy = Version::parse(self.tag_name.strip_prefix('v')?).ok()?; + if !legacy.pre.is_empty() { + return None; + } + self.assets + .iter() + .filter_map(|asset| { + let name = asset.name.strip_prefix("cheburprobe-")?; + let version = name + .strip_suffix("-linux-amd64") + .or_else(|| name.strip_suffix("-linux-arm64")) + .or_else(|| name.strip_suffix("-windows-x86_64.exe"))?; + Version::parse(version) + .ok() + .filter(|version| version.pre.is_empty()) + }) + .max_by(Version::cmp_precedence)? + }; + version.pre.is_empty().then_some(version) + } +} + +// Keep the first legacy release only as a fallback. Component releases take +// precedence, and the caller stops pagination as soon as one is found. +fn select_probe_release( + candidate: GithubRelease, + legacy: &mut Option, +) -> Option { + candidate.probe_version()?; + if candidate.tag_name.starts_with("probe-v") { + return Some(candidate); + } + if legacy.is_none() { + *legacy = Some(candidate); + } + None +} + #[derive(Clone, Debug, Deserialize)] struct GithubAsset { id: u64, @@ -107,7 +166,33 @@ impl ProbeUpdateProxy { } } - async fn release(&self) -> Result { + async fn latest_github_release(&self, releases_url: &str) -> anyhow::Result { + let mut legacy = None; + let mut page = 1_u64; + loop { + let releases = self + .github_request(releases_url, "application/vnd.github+json") + .query(&[("per_page", 100), ("page", page)]) + .send() + .await? + .error_for_status()? + .json::>() + .await?; + let last_page = releases.len() < 100; + for release in releases { + if let Some(release) = select_probe_release(release, &mut legacy) { + return Ok(release); + } + } + if last_page { + break; + } + page += 1; + } + legacy.context("no published stable probe release with probe assets found") + } + + async fn release(&self) -> anyhow::Result { let mut cache = self.cache.lock().await; if let Some(cached) = cache.as_ref() && cached.fetched_at.elapsed() < self.cache_ttl @@ -115,14 +200,8 @@ impl ProbeUpdateProxy { return Ok(cached.release.clone()); } - let url = format!("{GITHUB_API_BASE_URL}/repos/{DEFAULT_REPOSITORY}/releases/latest"); - let github_release = self - .github_request(&url, "application/vnd.github+json") - .send() - .await? - .error_for_status()? - .json::() - .await?; + let url = format!("{GITHUB_API_BASE_URL}/repos/{DEFAULT_REPOSITORY}/releases"); + let github_release = self.latest_github_release(&url).await?; let release = Release { assets: github_release .assets @@ -270,6 +349,118 @@ fn valid_asset_name(name: &str) -> bool { mod tests { use super::*; + fn release(tag: &str, probe_version: &str) -> GithubRelease { + GithubRelease { + tag_name: tag.to_owned(), + draft: false, + prerelease: false, + assets: vec![GithubAsset { + id: 42, + name: format!("cheburprobe-{probe_version}-linux-amd64"), + }], + } + } + + #[test] + fn selects_first_stable_probe_release_in_api_order() { + let mut legacy = None; + let selected = [ + release("website-v9.0.0", "9.0.0"), + release("reporter-v9.0.0", "9.0.0"), + release("probe-v0.9.0", "0.9.0"), + release("probe-v0.10.0", "0.10.0"), + ] + .into_iter() + .find_map(|candidate| select_probe_release(candidate, &mut legacy)); + assert_eq!(selected.unwrap().tag_name, "probe-v0.9.0"); + } + + #[test] + fn ignores_drafts_prereleases_invalid_tags_and_missing_probe_assets() { + let mut draft = release("probe-v8.0.0", "8.0.0"); + draft.draft = true; + let mut prerelease = release("probe-v9.0.0", "9.0.0"); + prerelease.prerelease = true; + let mut empty = release("probe-v10.0.0", "10.0.0"); + empty.assets.clear(); + let mut luci_only = release("probe-v11.0.0", "11.0.0"); + luci_only.assets[0].name = "luci-app-cheburprobe-11.0.0-r1.apk".to_owned(); + for candidate in [ + draft, + prerelease, + empty, + luci_only, + release("probe-v12.0.0-rc.1", "12.0.0-rc.1"), + release("probe-vinvalid", "13.0.0"), + release("v14.0.0-rc.1", "14.0.0"), + release("v14.0.0", "14.0.0-rc.1"), + ] { + assert_eq!(candidate.probe_version(), None, "{}", candidate.tag_name); + } + } + + #[test] + fn keeps_first_legacy_release_as_fallback_but_prefers_component_release() { + let mut legacy = None; + assert_eq!( + release("v9.0.0", "0.6.4").probe_version(), + Some(Version::new(0, 6, 4)) + ); + assert!(select_probe_release(release("v9.0.0", "0.6.4"), &mut legacy).is_none()); + assert!(select_probe_release(release("v10.0.0", "0.6.5"), &mut legacy).is_none()); + assert_eq!(legacy.as_ref().unwrap().tag_name, "v9.0.0"); + let selected = select_probe_release(release("probe-v0.6.3", "0.6.3"), &mut legacy); + assert_eq!(selected.unwrap().tag_name, "probe-v0.6.3"); + } + + // Both pages are full: returning the match must avoid requesting page three. + // Page one contains only releases of a different component. + #[rocket::async_test] + async fn stops_pagination_on_first_matching_release() { + use rocket::tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = rocket::tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .unwrap(); + let url = format!("http://{}/releases", listener.local_addr().unwrap()); + let server = rocket::tokio::spawn(async move { + for page in 1..=2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + loop { + let mut buffer = [0; 1024]; + let count = stream.read(&mut buffer).await.unwrap(); + assert!(count > 0); + request.extend_from_slice(&buffer[..count]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let request = String::from_utf8(request).unwrap(); + assert!(request.starts_with(&format!("GET /releases?per_page=100&page={page} "))); + let item = if page == 1 { + r#"{"tag_name":"website-v9.0.0","draft":false,"prerelease":false,"assets":[]}"# + } else { + r#"{"tag_name":"probe-v0.6.5","draft":false,"prerelease":false,"assets":[{"id":42,"name":"cheburprobe-0.6.5-linux-amd64"}]}"# + }; + let body = format!("[{}]", vec![item; 100].join(",")); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + } + }); + let mut proxy = ProbeUpdateProxy::from_env().unwrap(); + proxy.github_token = None; + let selected = + rocket::tokio::time::timeout(Duration::from_secs(5), proxy.latest_github_release(&url)) + .await + .unwrap() + .unwrap(); + assert_eq!(selected.tag_name, "probe-v0.6.5"); + server.await.unwrap(); + } + #[test] fn accepts_only_probe_release_asset_names() { assert!(valid_asset_name("cheburprobe-0.6.0-linux-amd64"));