Skip to main content

rpi_cli/
prebuilt_extensions.rs

1//! Versioned prebuilt extension distribution (rpi-extension.json v1).
2//! Repository discovery uses crates.io metadata. Release tags and library
3//! paths are protocol fields, never guessed from asset names or repository latest.
4
5use std::collections::BTreeMap;
6use std::io::{Cursor, Read};
7use std::path::{Component, Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12const CRATES_API: &str = "https://crates.io/api/v1/crates";
13const MAX_METADATA: u64 = 8 * 1024 * 1024;
14const MAX_MANIFEST: u64 = 256 * 1024;
15const MAX_ARCHIVE: u64 = 256 * 1024 * 1024;
16const MAX_LIBRARY: u64 = 128 * 1024 * 1024;
17const MAX_ENTRIES: usize = 128;
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20pub struct PrebuiltRecord {
21    pub repository: String,
22    pub target: String,
23    pub manifest_sha256: String,
24    pub source_commit: String,
25    pub sdk_version: String,
26    pub plugin_abi: u32,
27    pub archive_sha256: String,
28    pub library_sha256: String,
29    /// Explicit repository installs discover versions from package-specific tags.
30    #[serde(default)]
31    pub custom_repository: bool,
32}
33
34#[derive(Debug, Deserialize)]
35struct RegistryPackage {
36    #[serde(rename = "crate")]
37    metadata: RegistryMetadata,
38    versions: Vec<RegistryVersion>,
39}
40
41#[derive(Debug, Deserialize)]
42struct RegistryMetadata {
43    id: String,
44    repository: Option<String>,
45}
46
47#[derive(Debug, Deserialize)]
48struct RegistryVersion {
49    num: String,
50    yanked: bool,
51}
52
53#[derive(Debug, Deserialize)]
54struct Manifest {
55    schema_version: u32,
56    name: String,
57    version: String,
58    repository: String,
59    tag: String,
60    source_commit: String,
61    sdk: Sdk,
62    targets: BTreeMap<String, Distribution>,
63}
64
65#[derive(Debug, Deserialize)]
66struct Sdk {
67    version: String,
68    plugin_abi: u32,
69}
70
71#[derive(Debug, Deserialize)]
72struct Distribution {
73    url: String,
74    format: String,
75    size: u64,
76    sha256: String,
77    library: Library,
78}
79
80#[derive(Debug, Deserialize)]
81struct Library {
82    path: String,
83    sha256: String,
84}
85
86struct ResolvedPackage {
87    name: String,
88    version: String,
89    repository: String,
90    custom_repository: bool,
91}
92
93#[derive(Deserialize)]
94struct GitReference {
95    #[serde(rename = "ref")]
96    reference: String,
97}
98
99fn stable_tag_version(refs: &[GitReference], name: &str) -> Result<String, String> {
100    let prefix = format!("refs/tags/{name}-v");
101    refs.iter().filter_map(|r| r.reference.strip_prefix(&prefix))
102        .filter_map(|v| semver::Version::parse(v).ok()).filter(|v| v.pre.is_empty())
103        .max().map(|v| v.to_string()).ok_or_else(|| format!("no stable {name}-v<version> tag exists in this repository; specify --version explicitly"))
104}
105
106async fn repository_version(
107    client: &reqwest::Client,
108    api: &str,
109    name: &str,
110    repository: &str,
111) -> Result<String, String> {
112    let repository = canonical_repository(repository)?;
113    let repo = repository
114        .strip_prefix("https://github.com/")
115        .ok_or("invalid GitHub repository")?;
116    let response = client
117        .get(format!(
118            "{api}/repos/{repo}/git/matching-refs/tags/{name}-v"
119        ))
120        .header(reqwest::header::ACCEPT, "application/vnd.github+json")
121        .send()
122        .await
123        .map_err(|e| format!("GitHub tag lookup failed: {e}"))?;
124    if !response.status().is_success() {
125        return Err(format!("GitHub tag lookup returned {}", response.status()));
126    }
127    let refs: Vec<GitReference> =
128        serde_json::from_slice(&body_limited(response, MAX_METADATA).await?)
129            .map_err(|e| format!("invalid GitHub tag metadata: {e}"))?;
130    stable_tag_version(&refs, name)
131}
132
133/// The five supported triples are deliberately exact: GNU/musl and MSVC/GNU
134/// are different native-library distributions.
135pub fn host_target() -> Result<&'static str, String> {
136    match (std::env::consts::OS, std::env::consts::ARCH) {
137        ("windows", "x86_64") if cfg!(target_env = "msvc") => Ok("x86_64-pc-windows-msvc"),
138        ("linux", "x86_64") if cfg!(target_env = "gnu") => Ok("x86_64-unknown-linux-gnu"),
139        ("linux", "aarch64") if cfg!(target_env = "gnu") => Ok("aarch64-unknown-linux-gnu"),
140        ("macos", "x86_64") => Ok("x86_64-apple-darwin"),
141        ("macos", "aarch64") => Ok("aarch64-apple-darwin"),
142        _ => Err(
143            "no supported prebuilt target for this host; use --build to compile explicitly".into(),
144        ),
145    }
146}
147
148fn valid_segment(value: &str) -> bool {
149    !value.is_empty()
150        && !matches!(value, "." | "..")
151        && value
152            .bytes()
153            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
154}
155
156/// Accept an explicit owner/repo override or repository metadata URLs,
157/// including Cargo's common .git / git+https forms and GitHub tree subpaths.
158pub(crate) fn canonical_repository(value: &str) -> Result<String, String> {
159    let value = value.trim().strip_prefix("git+").unwrap_or(value.trim());
160    let value = if value.contains("://") {
161        value.to_string()
162    } else {
163        format!("https://github.com/{value}")
164    };
165    let url = reqwest::Url::parse(&value).map_err(|_| "invalid GitHub repository".to_string())?;
166    if url.scheme() != "https"
167        || url.host_str() != Some("github.com")
168        || !url.username().is_empty()
169        || url.password().is_some()
170        || url.port().is_some()
171    {
172        return Err("prebuilt repository must be an HTTPS GitHub repository; use --build for other repositories".into());
173    }
174    let segments: Vec<_> = url
175        .path_segments()
176        .ok_or("invalid GitHub repository path")?
177        .collect();
178    if segments.len() < 2 || !valid_segment(segments[0]) {
179        return Err("repository must identify a GitHub owner and repository".into());
180    }
181    let repo = segments[1].strip_suffix(".git").unwrap_or(segments[1]);
182    if !valid_segment(repo) {
183        return Err("invalid GitHub repository name".into());
184    }
185    if segments.len() > 2
186        && !segments[2..].iter().all(|s| s.is_empty())
187        && !matches!(segments[2], "tree" | "blob")
188    {
189        return Err("unsupported GitHub repository URL path".into());
190    }
191    Ok(format!(
192        "https://github.com/{}/{}",
193        segments[0].to_ascii_lowercase(),
194        repo.to_ascii_lowercase()
195    ))
196}
197
198fn release_url(repository: &str, name: &str, version: &str, file: &str) -> Result<String, String> {
199    let mut url = reqwest::Url::parse(repository).map_err(|e| e.to_string())?;
200    url.path_segments_mut()
201        .map_err(|_| "invalid release repository")?
202        .push("releases")
203        .push("download")
204        .push(&format!("{name}-v{version}"))
205        .push(file);
206    Ok(url.into())
207}
208
209fn select_version(versions: &[RegistryVersion], requested: Option<&str>) -> Result<String, String> {
210    if let Some(requested) = requested {
211        let version = semver::Version::parse(requested)
212            .map_err(|_| "--version requires an exact semantic version".to_string())?;
213        return versions
214            .iter()
215            .find(|v| !v.yanked && v.num == version.to_string())
216            .map(|v| v.num.clone())
217            .ok_or_else(|| format!("version {requested} is absent or yanked on crates.io"));
218    }
219    versions
220        .iter()
221        .filter(|v| !v.yanked)
222        .filter_map(|v| semver::Version::parse(&v.num).ok())
223        .filter(|v| v.pre.is_empty())
224        .max()
225        .map(|v| v.to_string())
226        .ok_or_else(|| "no non-yanked stable version is available".into())
227}
228
229pub(crate) async fn body_limited(
230    mut response: reqwest::Response,
231    limit: u64,
232) -> Result<Vec<u8>, String> {
233    if response.content_length().is_some_and(|n| n > limit) {
234        return Err("download exceeds size limit".into());
235    }
236    let mut bytes = Vec::new();
237    while let Some(chunk) = response
238        .chunk()
239        .await
240        .map_err(|e| format!("download failed: {e}"))?
241    {
242        if bytes.len() as u64 + chunk.len() as u64 > limit {
243            return Err("download exceeds size limit".into());
244        }
245        bytes.extend_from_slice(&chunk);
246    }
247    Ok(bytes)
248}
249
250async fn resolve_package(
251    client: &reqwest::Client,
252    api: &str,
253    name: &str,
254    version: Option<&str>,
255    repository: Option<&str>,
256) -> Result<ResolvedPackage, String> {
257    // An explicit repository + version also supports unpublished third-party extensions.
258    if let Some(repository) = repository {
259        let repository = canonical_repository(repository)?;
260        let version = match version {
261            Some(version) => semver::Version::parse(version)
262                .map_err(|_| "--version requires an exact semantic version".to_string())?
263                .to_string(),
264            None => repository_version(client, "https://api.github.com", name, &repository).await?,
265        };
266        return Ok(ResolvedPackage {
267            name: name.into(),
268            version,
269            repository,
270            custom_repository: true,
271        });
272    }
273    let response = client
274        .get(format!("{api}/{name}"))
275        .send()
276        .await
277        .map_err(|e| format!("crates.io lookup failed: {e}"))?;
278    if !response.status().is_success() {
279        return Err(format!(
280            "crates.io lookup for {name} returned {}",
281            response.status()
282        ));
283    }
284    let data: RegistryPackage =
285        serde_json::from_slice(&body_limited(response, MAX_METADATA).await?)
286            .map_err(|e| format!("invalid crates.io metadata: {e}"))?;
287    if !data
288        .metadata
289        .id
290        .replace('-', "_")
291        .eq_ignore_ascii_case(&name.replace('-', "_"))
292    {
293        return Err("crates.io metadata identifies a different package".into());
294    }
295    let version = select_version(&data.versions, version)?;
296    let repository = repository
297        .or(data.metadata.repository.as_deref())
298        .ok_or("crate has no repository metadata; use --github owner/repo or --build")?;
299    Ok(ResolvedPackage {
300        name: data.metadata.id,
301        version,
302        repository: canonical_repository(repository)?,
303        custom_repository: false,
304    })
305}
306
307pub(crate) fn hash(bytes: &[u8]) -> String {
308    format!("{:x}", Sha256::digest(bytes))
309}
310
311pub(crate) fn check_hash(bytes: &[u8], expected: &str) -> Result<(), String> {
312    if expected.len() != 64
313        || !expected
314            .bytes()
315            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
316    {
317        return Err("manifest SHA-256 must be 64 lowercase hexadecimal characters".into());
318    }
319    if hash(bytes) != expected {
320        return Err("SHA-256 mismatch".into());
321    }
322    Ok(())
323}
324
325fn validate_manifest<'a>(
326    manifest: &'a Manifest,
327    package: &ResolvedPackage,
328    target: &str,
329) -> Result<&'a Distribution, String> {
330    if manifest.schema_version != 1 {
331        return Err(format!(
332            "unsupported prebuilt manifest schema {}",
333            manifest.schema_version
334        ));
335    }
336    if manifest.name != package.name
337        || manifest.version != package.version
338        || manifest.tag != format!("{}-v{}", package.name, package.version)
339        || canonical_repository(&manifest.repository)? != package.repository
340    {
341        return Err("prebuilt manifest package, version, repository or tag does not match the requested package".into());
342    }
343    if manifest.source_commit.len() != 40
344        || !manifest
345            .source_commit
346            .bytes()
347            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
348    {
349        return Err("manifest source_commit must be a full Git commit SHA".into());
350    }
351    semver::Version::parse(&manifest.sdk.version).map_err(|_| "invalid SDK version in manifest")?;
352    if manifest.sdk.plugin_abi != rpi_plugin_sdk::RPI_PLUGIN_ABI_VERSION_UNIFIED {
353        return Err(format!(
354            "extension SDK ABI {} is incompatible with host ABI {}",
355            manifest.sdk.plugin_abi,
356            rpi_plugin_sdk::RPI_PLUGIN_ABI_VERSION_UNIFIED
357        ));
358    }
359    let distribution = manifest.targets.get(target).ok_or_else(|| {
360        format!(
361            "no prebuilt extension for exact target {target}; use --build to compile explicitly"
362        )
363    })?;
364    if distribution.size == 0
365        || distribution.size > MAX_ARCHIVE
366        || !matches!(distribution.format.as_str(), "zip" | "tar.gz")
367    {
368        return Err("unsupported archive format or size in manifest".into());
369    }
370    let name = &distribution.library.path;
371    let extension = if target.contains("windows") {
372        ".dll"
373    } else if target.contains("apple") {
374        ".dylib"
375    } else {
376        ".so"
377    };
378    if name.is_empty()
379        || !name.ends_with(extension)
380        || name.contains(['/', '\\', ':'])
381        || !name
382            .bytes()
383            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
384        || matches!(name.as_str(), "." | "..")
385    {
386        return Err(
387            "manifest library.path must be a flat library filename for the selected target".into(),
388        );
389    }
390    for expected in [&distribution.sha256, &distribution.library.sha256] {
391        if expected.len() != 64
392            || !expected
393                .bytes()
394                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
395        {
396            return Err("invalid SHA-256 in manifest".into());
397        }
398    }
399    validate_release_asset_url(&distribution.url, &package.repository, &manifest.tag)?;
400    Ok(distribution)
401}
402
403pub(crate) fn validate_release_asset_url(
404    value: &str,
405    repository: &str,
406    tag: &str,
407) -> Result<(), String> {
408    let url = reqwest::Url::parse(value).map_err(|_| "invalid archive URL")?;
409    let segments: Vec<String> = url
410        .path_segments()
411        .ok_or("archive URL has no path")?
412        .map(|s| {
413            percent_encoding::percent_decode_str(s)
414                .decode_utf8()
415                .map(|s| s.into_owned())
416                .map_err(|_| "invalid encoded archive URL")
417        })
418        .collect::<Result<_, _>>()?;
419    if url.scheme() != "https"
420        || url.host_str() != Some("github.com")
421        || url.port().is_some()
422        || !url.username().is_empty()
423        || url.password().is_some()
424        || url.query().is_some()
425        || url.fragment().is_some()
426        || segments.len() != 6
427        || segments[2] != "releases"
428        || segments[3] != "download"
429        || segments[4] != tag
430        || canonical_repository(&format!(
431            "https://github.com/{}/{}",
432            segments[0], segments[1]
433        ))? != repository
434        || segments[5].is_empty()
435        || matches!(segments[5].as_str(), "." | "..")
436        || !segments[5]
437            .bytes()
438            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'+'))
439    {
440        return Err(
441            "archive URL must belong to the same GitHub repository and package Release".into(),
442        );
443    }
444    Ok(())
445}
446
447async fn fetch_manifest(
448    client: &reqwest::Client,
449    package: &ResolvedPackage,
450) -> Result<(Manifest, Vec<u8>), String> {
451    let url = release_url(
452        &package.repository,
453        &package.name,
454        &package.version,
455        "rpi-extension.json",
456    )?;
457    let response = client
458        .get(&url)
459        .send()
460        .await
461        .map_err(|e| format!("prebuilt manifest request failed: {e}"))?;
462    read_manifest_response(response, package).await
463}
464
465async fn read_manifest_response(
466    response: reqwest::Response,
467    package: &ResolvedPackage,
468) -> Result<(Manifest, Vec<u8>), String> {
469    if response.status() == reqwest::StatusCode::NOT_FOUND {
470        return Err(format!("prebuilt release for {} {} is not ready (rpi-extension.json is missing); retry later or use --build explicitly", package.name, package.version));
471    }
472    if !response.status().is_success() {
473        return Err(format!(
474            "prebuilt manifest request returned {}",
475            response.status()
476        ));
477    }
478    let bytes = body_limited(response, MAX_MANIFEST).await?;
479    let manifest =
480        serde_json::from_slice(&bytes).map_err(|e| format!("invalid rpi-extension.json: {e}"))?;
481    Ok((manifest, bytes))
482}
483
484/// Only advertise an update when its exact platform manifest is available and compatible.
485pub async fn latest_compatible_version(
486    client: &reqwest::Client,
487    package: &crate::install::InstalledNativePackage,
488) -> Result<Option<String>, String> {
489    let Some(record) = &package.prebuilt else {
490        return Ok(None);
491    };
492    let repository = record
493        .custom_repository
494        .then_some(record.repository.as_str());
495    let resolved = resolve_package(client, CRATES_API, &package.name, None, repository).await?;
496    if resolved.repository != record.repository {
497        return Err(
498            "extension repository changed; reinstall explicitly to select a new publisher".into(),
499        );
500    }
501    if !crate::updates::is_newer(&package.version, &resolved.version) {
502        return Ok(Some(resolved.version));
503    }
504    let (manifest, _) = fetch_manifest(client, &resolved).await?;
505    validate_manifest(&manifest, &resolved, host_target()?)?;
506    Ok(Some(resolved.version))
507}
508
509pub async fn install(
510    name: &str,
511    version: Option<&str>,
512    repository: Option<&str>,
513    force: bool,
514) -> Result<(), String> {
515    let records = crate::install::installed_native_packages_strict()?;
516    let existing = records.iter().find(|p| p.name == name);
517    if existing.is_some() && !force {
518        return Err(format!(
519            "{name} is already installed; use --force to replace it"
520        ));
521    }
522    let target = host_target()?;
523    let client = reqwest::Client::builder()
524        .timeout(std::time::Duration::from_secs(15))
525        .user_agent(format!("rpi/{}", crate::VERSION))
526        .build()
527        .map_err(|e| e.to_string())?;
528    let repository = repository.or_else(|| {
529        existing
530            .and_then(|p| p.prebuilt.as_ref())
531            .filter(|record| record.custom_repository)
532            .map(|r| r.repository.as_str())
533    });
534    let package = resolve_package(&client, CRATES_API, name, version, repository).await?;
535    if let Some(record) = existing.and_then(|p| p.prebuilt.as_ref()) {
536        if repository.is_none() && record.repository != package.repository {
537            return Err(
538                "extension repository changed; select the new repository explicitly with --github"
539                    .into(),
540            );
541        }
542    }
543    println!(
544        "downloading prebuilt {} {} for {target}",
545        package.name, package.version
546    );
547    let (manifest, manifest_bytes) = fetch_manifest(&client, &package).await?;
548    let distribution = validate_manifest(&manifest, &package, target)?;
549    let response = client
550        .get(&distribution.url)
551        .timeout(std::time::Duration::from_secs(180))
552        .send()
553        .await
554        .map_err(|e| format!("archive request failed: {e}"))?;
555    if !response.status().is_success() {
556        return Err(format!("archive request returned {}", response.status()));
557    }
558    let archive = body_limited(response, distribution.size).await?;
559    let agent_dir = crate::config::agent_dir().map_err(|e| e.to_string())?;
560    let target = target.to_string();
561    let installed = tokio::task::spawn_blocking(move || {
562        install_archive_at(
563            &package,
564            &manifest,
565            &manifest_bytes,
566            &archive,
567            &target,
568            &agent_dir,
569            force,
570        )
571    })
572    .await
573    .map_err(|e| format!("prebuilt installation failed: {e}"))??;
574    for path in installed {
575        println!("installed {}", path.display());
576    }
577    println!("rpi will validate the plugin entrypoint/SDK API size and load this extension on the next start.");
578    Ok(())
579}
580
581fn install_archive_at(
582    package: &ResolvedPackage,
583    manifest: &Manifest,
584    manifest_bytes: &[u8],
585    archive: &[u8],
586    target: &str,
587    agent_dir: &Path,
588    force: bool,
589) -> Result<Vec<PathBuf>, String> {
590    let distribution = validate_manifest(manifest, package, target)?;
591    if archive.len() as u64 != distribution.size {
592        return Err("archive byte length does not match manifest".into());
593    }
594    check_hash(archive, &distribution.sha256)?;
595    let bytes = extract_library(archive, &distribution.format, &distribution.library.path)?;
596    check_hash(&bytes, &distribution.library.sha256)?;
597    validate_library_target(&bytes, target)?;
598    let provenance = PrebuiltRecord {
599        repository: package.repository.clone(),
600        target: target.into(),
601        manifest_sha256: hash(manifest_bytes),
602        source_commit: manifest.source_commit.clone(),
603        sdk_version: manifest.sdk.version.clone(),
604        plugin_abi: manifest.sdk.plugin_abi,
605        archive_sha256: distribution.sha256.clone(),
606        library_sha256: distribution.library.sha256.clone(),
607        custom_repository: package.custom_repository,
608    };
609    let temporary = tempfile::tempdir().map_err(|e| e.to_string())?;
610    let artifact = temporary.path().join(&distribution.library.path);
611    std::fs::write(&artifact, bytes).map_err(|e| e.to_string())?;
612    let record = crate::install::InstalledNativePackage {
613        name: package.name.clone(),
614        version: package.version.clone(),
615        // Older hosts regard this as a non-registry source and will not rebuild it.
616        source: Some(format!("prebuilt:{}", package.repository)),
617        prebuilt: Some(provenance),
618        artifacts: Vec::new(),
619    };
620    crate::install::install_native_package_at(
621        &agent_dir.join("extensions"),
622        &agent_dir.join("native-packages.json"),
623        record,
624        &[artifact],
625        force,
626    )
627}
628
629fn safe_entry_name(name: &str) -> Result<PathBuf, String> {
630    if name.contains(['\\', ':'])
631        || Path::new(name).components().any(|p| {
632            matches!(
633                p,
634                Component::ParentDir | Component::RootDir | Component::Prefix(_)
635            )
636        })
637    {
638        return Err("unsafe archive entry path".into());
639    }
640    Ok(Path::new(name)
641        .components()
642        .filter(|c| !matches!(c, Component::CurDir))
643        .collect())
644}
645
646fn read_library(reader: impl Read) -> Result<Vec<u8>, String> {
647    let mut bytes = Vec::new();
648    reader
649        .take(MAX_LIBRARY + 1)
650        .read_to_end(&mut bytes)
651        .map_err(|e| e.to_string())?;
652    if bytes.len() as u64 > MAX_LIBRARY {
653        return Err("extracted library exceeds size limit".into());
654    }
655    Ok(bytes)
656}
657
658pub(crate) fn extract_library(
659    archive: &[u8],
660    format: &str,
661    library: &str,
662) -> Result<Vec<u8>, String> {
663    let mut found = None;
664    let mut total = 0u64;
665    match format {
666        "zip" => {
667            let mut zip = zip::ZipArchive::new(Cursor::new(archive)).map_err(|e| e.to_string())?;
668            if zip.len() > MAX_ENTRIES {
669                return Err("too many archive entries".into());
670            }
671            for i in 0..zip.len() {
672                let file = zip.by_index(i).map_err(|e| e.to_string())?;
673                let path = safe_entry_name(file.name())?;
674                if file
675                    .unix_mode()
676                    .is_some_and(|mode| mode & 0o170000 == 0o120000)
677                {
678                    return Err("archive symlinks are not allowed".into());
679                }
680                total = total
681                    .checked_add(file.size())
682                    .ok_or("archive size overflow")?;
683                if total > MAX_ARCHIVE {
684                    return Err("expanded archive exceeds size limit".into());
685                }
686                if !file.is_dir() && path == Path::new(library) {
687                    if found.is_some() {
688                        return Err("duplicate declared library in archive".into());
689                    }
690                    found = Some(read_library(file)?);
691                }
692            }
693        }
694        "tar.gz" => {
695            let decoder = flate2::read::GzDecoder::new(Cursor::new(archive)).take(MAX_ARCHIVE + 1);
696            let mut tar = tar::Archive::new(decoder);
697            for (i, entry) in tar.entries().map_err(|e| e.to_string())?.enumerate() {
698                if i >= MAX_ENTRIES {
699                    return Err("too many archive entries".into());
700                }
701                let file = entry.map_err(|e| e.to_string())?;
702                let name = file
703                    .path()
704                    .map_err(|e| e.to_string())?
705                    .to_string_lossy()
706                    .into_owned();
707                let path = safe_entry_name(&name)?;
708                let kind = file.header().entry_type();
709                if !kind.is_file() && !kind.is_dir() {
710                    return Err("archive links and special files are not allowed".into());
711                }
712                total = total
713                    .checked_add(file.size())
714                    .ok_or("archive size overflow")?;
715                if total > MAX_ARCHIVE {
716                    return Err("expanded archive exceeds size limit".into());
717                }
718                if kind.is_file() && path == Path::new(library) {
719                    if found.is_some() {
720                        return Err("duplicate declared library in archive".into());
721                    }
722                    found = Some(read_library(file)?);
723                }
724            }
725        }
726        _ => return Err("unsupported prebuilt archive format".into()),
727    }
728    found.ok_or_else(|| format!("archive does not contain the exact declared library {library}"))
729}
730
731/// Verify the native object format/architecture without executing downloaded code.
732fn validate_library_target(bytes: &[u8], target: &str) -> Result<(), String> {
733    validate_native_target(bytes, target, true)
734}
735
736pub(crate) fn validate_executable_target(bytes: &[u8], target: &str) -> Result<(), String> {
737    validate_native_target(bytes, target, false)
738}
739
740fn validate_native_target(bytes: &[u8], target: &str, library: bool) -> Result<(), String> {
741    let u16_at = |n: usize| {
742        bytes
743            .get(n..n.checked_add(2)?)
744            .map(|b| u16::from_le_bytes([b[0], b[1]]))
745    };
746    let u32_at = |n: usize| {
747        bytes
748            .get(n..n.checked_add(4)?)
749            .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
750    };
751    let valid = match target {
752        "x86_64-pc-windows-msvc" => {
753            let offset = u32_at(0x3c).unwrap_or(u32::MAX) as usize;
754            bytes.starts_with(b"MZ")
755                && bytes.get(offset..offset.saturating_add(4)) == Some(b"PE\0\0".as_slice())
756                && u16_at(offset.saturating_add(4)) == Some(0x8664)
757                && u16_at(offset.saturating_add(22)).is_some_and(|flags| {
758                    if library {
759                        flags & 0x2000 != 0
760                    } else {
761                        flags & 0x2000 == 0 && flags & 2 != 0
762                    }
763                })
764        }
765        "x86_64-unknown-linux-gnu" | "aarch64-unknown-linux-gnu" => {
766            bytes.starts_with(b"\x7fELF\x02\x01")
767                && u16_at(16).is_some_and(|kind| kind == 3 || (!library && kind == 2))
768                && u16_at(18)
769                    == Some(if target.starts_with("aarch64") {
770                        183
771                    } else {
772                        62
773                    })
774        }
775        "x86_64-apple-darwin" | "aarch64-apple-darwin" => {
776            u32_at(0) == Some(0xfeedfacf)
777                && u32_at(12) == Some(if library { 6 } else { 2 })
778                && u32_at(4)
779                    == Some(if target.starts_with("aarch64") {
780                        0x0100000c
781                    } else {
782                        0x01000007
783                    })
784        }
785        _ => false,
786    };
787    if !valid {
788        return Err(format!(
789            "declared artifact is not a compatible native {} for {target}",
790            if library {
791                "shared library"
792            } else {
793                "executable"
794            }
795        ));
796    }
797    Ok(())
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803    use std::io::Write;
804
805    const TARGETS: [&str; 5] = [
806        "x86_64-pc-windows-msvc",
807        "x86_64-unknown-linux-gnu",
808        "aarch64-unknown-linux-gnu",
809        "x86_64-apple-darwin",
810        "aarch64-apple-darwin",
811    ];
812
813    fn object(target: &str) -> Vec<u8> {
814        let mut bytes = vec![0u8; 128];
815        if target.contains("windows") {
816            bytes[..2].copy_from_slice(b"MZ");
817            bytes[0x3c..0x40].copy_from_slice(&64u32.to_le_bytes());
818            bytes[64..68].copy_from_slice(b"PE\0\0");
819            bytes[68..70].copy_from_slice(&0x8664u16.to_le_bytes());
820            bytes[86..88].copy_from_slice(&0x2000u16.to_le_bytes());
821        } else if target.contains("linux") {
822            bytes[..6].copy_from_slice(b"\x7fELF\x02\x01");
823            bytes[16..18].copy_from_slice(&3u16.to_le_bytes());
824            bytes[18..20].copy_from_slice(
825                &(if target.starts_with("aarch64") {
826                    183u16
827                } else {
828                    62u16
829                })
830                .to_le_bytes(),
831            );
832        } else {
833            bytes[..4].copy_from_slice(&0xfeedfacfu32.to_le_bytes());
834            bytes[4..8].copy_from_slice(
835                &(if target.starts_with("aarch64") {
836                    0x0100000cu32
837                } else {
838                    0x01000007u32
839                })
840                .to_le_bytes(),
841            );
842            bytes[12..16].copy_from_slice(&6u32.to_le_bytes());
843        }
844        bytes
845    }
846
847    fn zip(files: &[(&str, &[u8])]) -> Vec<u8> {
848        let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
849        for (name, bytes) in files {
850            zip.start_file(*name, zip::write::SimpleFileOptions::default())
851                .unwrap();
852            zip.write_all(bytes).unwrap();
853        }
854        zip.finish().unwrap().into_inner()
855    }
856
857    fn tgz(name: &str, bytes: &[u8]) -> Vec<u8> {
858        let mut tar = tar::Builder::new(flate2::write::GzEncoder::new(
859            Vec::new(),
860            flate2::Compression::default(),
861        ));
862        let mut header = tar::Header::new_gnu();
863        header.set_size(bytes.len() as u64);
864        header.set_mode(0o644);
865        header.set_cksum();
866        tar.append_data(&mut header, name, bytes).unwrap();
867        tar.into_inner().unwrap().finish().unwrap()
868    }
869
870    fn fixture(target: &str) -> (ResolvedPackage, serde_json::Value, Vec<u8>) {
871        let package = ResolvedPackage {
872            name: "independent-plugin".into(),
873            version: "1.2.3".into(),
874            repository: "https://github.com/publisher/monorepo".into(),
875            custom_repository: false,
876        };
877        // The library target deliberately differs from the crate name.
878        let (format, library, file) = if target.contains("windows") {
879            ("zip", "custom_runtime.dll", "package.zip")
880        } else if target.contains("apple") {
881            ("tar.gz", "libcustom_runtime.dylib", "package.tar.gz")
882        } else {
883            ("tar.gz", "libcustom_runtime.so", "package.tar.gz")
884        };
885        let bytes = object(target);
886        let archive = if format == "zip" {
887            zip(&[(library, &bytes)])
888        } else {
889            tgz(library, &bytes)
890        };
891        let manifest = serde_json::json!({
892            "schema_version":1, "name":package.name, "version":package.version,
893            "repository":package.repository, "tag":"independent-plugin-v1.2.3", "source_commit":"a".repeat(40),
894            "sdk":{"version":"0.3.23", "plugin_abi":rpi_plugin_sdk::RPI_PLUGIN_ABI_VERSION_UNIFIED},
895            "targets":{target:{"url":release_url(&package.repository,&package.name,&package.version,file).unwrap(),
896                "format":format,"size":archive.len(),"sha256":hash(&archive),"library":{"path":library,"sha256":hash(&bytes)}}}
897        });
898        (package, manifest, archive)
899    }
900
901    #[test]
902    fn all_five_distributions_install_and_record_exact_provenance() {
903        for target in TARGETS {
904            let (package, value, archive) = fixture(target);
905            let manifest: Manifest = serde_json::from_value(value.clone()).unwrap();
906            let raw = serde_json::to_vec(&value).unwrap();
907            let agent = tempfile::tempdir().unwrap();
908            let paths = install_archive_at(
909                &package,
910                &manifest,
911                &raw,
912                &archive,
913                target,
914                agent.path(),
915                false,
916            )
917            .unwrap();
918            assert_eq!(paths.len(), 1);
919            assert_eq!(std::fs::read(&paths[0]).unwrap(), object(target));
920            let records: Vec<crate::install::InstalledNativePackage> = serde_json::from_slice(
921                &std::fs::read(agent.path().join("native-packages.json")).unwrap(),
922            )
923            .unwrap();
924            assert_eq!(records[0].name, "independent-plugin");
925            assert_eq!(records[0].version, "1.2.3");
926            assert_eq!(
927                records[0].artifacts,
928                [manifest.targets[target].library.path.clone()]
929            );
930            let origin = records[0].prebuilt.as_ref().unwrap();
931            assert_eq!(origin.target, target);
932            assert_eq!(origin.manifest_sha256, hash(&raw));
933            assert_eq!(origin.repository, package.repository);
934            assert_eq!(origin.archive_sha256, hash(&archive));
935            assert_eq!(origin.library_sha256, hash(&object(target)));
936        }
937    }
938
939    #[test]
940    fn bad_release_identity_hash_abi_and_target_leave_installed_version_intact() {
941        let target = TARGETS[0];
942        let (package, good, archive) = fixture(target);
943        let manifest: Manifest = serde_json::from_value(good.clone()).unwrap();
944        let agent = tempfile::tempdir().unwrap();
945        let path = install_archive_at(
946            &package,
947            &manifest,
948            &serde_json::to_vec(&good).unwrap(),
949            &archive,
950            target,
951            agent.path(),
952            false,
953        )
954        .unwrap()
955        .remove(0);
956        let original_record = std::fs::read(agent.path().join("native-packages.json")).unwrap();
957        let mutations = [
958            ("/schema_version",serde_json::json!(99)), ("/name",serde_json::json!("other-plugin")),
959            ("/version",serde_json::json!("9.9.9")), ("/tag",serde_json::json!("v1.2.3")),
960            ("/repository",serde_json::json!("https://github.com/other/repo")),
961            ("/sdk/plugin_abi",serde_json::json!(99)),
962            ("/targets/x86_64-pc-windows-msvc/sha256",serde_json::json!("0".repeat(64))),
963            ("/targets/x86_64-pc-windows-msvc/library/sha256",serde_json::json!("0".repeat(64))),
964            ("/targets/x86_64-pc-windows-msvc/library/path",serde_json::json!("../custom_runtime.dll")),
965            ("/targets/x86_64-pc-windows-msvc/url",serde_json::json!("https://github.com/other/repo/releases/download/independent-plugin-v1.2.3/package.zip")),
966            ("/targets/x86_64-pc-windows-msvc/size",serde_json::json!(archive.len()+1)),
967        ];
968        for (pointer, value) in mutations {
969            let mut bad = good.clone();
970            *bad.pointer_mut(pointer).unwrap() = value;
971            let bad_manifest = serde_json::from_value(bad.clone()).unwrap();
972            assert!(
973                install_archive_at(
974                    &package,
975                    &bad_manifest,
976                    &serde_json::to_vec(&bad).unwrap(),
977                    &archive,
978                    target,
979                    agent.path(),
980                    true
981                )
982                .is_err(),
983                "{pointer}"
984            );
985            assert_eq!(std::fs::read(&path).unwrap(), object(target));
986            assert_eq!(
987                std::fs::read(agent.path().join("native-packages.json")).unwrap(),
988                original_record
989            );
990        }
991        assert!(install_archive_at(
992            &package,
993            &manifest,
994            &[],
995            &archive,
996            TARGETS[1],
997            agent.path(),
998            true
999        )
1000        .is_err());
1001    }
1002
1003    #[test]
1004    fn same_abi_but_wrong_binary_architecture_is_rejected_before_activation() {
1005        let (package, mut value, _) = fixture(TARGETS[0]);
1006        let wrong = object(TARGETS[2]);
1007        let archive = zip(&[("custom_runtime.dll", &wrong)]);
1008        let distribution = value
1009            .pointer_mut("/targets/x86_64-pc-windows-msvc")
1010            .unwrap();
1011        distribution["size"] = serde_json::json!(archive.len());
1012        distribution["sha256"] = serde_json::json!(hash(&archive));
1013        distribution["library"]["sha256"] = serde_json::json!(hash(&wrong));
1014        let manifest = serde_json::from_value(value).unwrap();
1015        let agent = tempfile::tempdir().unwrap();
1016        assert!(install_archive_at(
1017            &package,
1018            &manifest,
1019            &[],
1020            &archive,
1021            TARGETS[0],
1022            agent.path(),
1023            false
1024        )
1025        .unwrap_err()
1026        .contains("shared library"));
1027        assert!(!agent.path().join("native-packages.json").exists());
1028        assert!(!agent.path().join("extensions").exists());
1029    }
1030
1031    #[test]
1032    fn archives_cannot_traverse_retain_links_or_substitute_another_library() {
1033        let library = object(TARGETS[0]);
1034        assert!(
1035            extract_library(&zip(&[("../escape.dll", &library)]), "zip", "escape.dll").is_err()
1036        );
1037        assert!(
1038            extract_library(&zip(&[("C:\\escape.dll", &library)]), "zip", "escape.dll").is_err()
1039        );
1040        assert!(extract_library(&zip(&[("other.dll", &library)]), "zip", "declared.dll").is_err());
1041        let mut linked = zip::ZipWriter::new(Cursor::new(Vec::new()));
1042        linked
1043            .add_symlink(
1044                "declared.dll",
1045                "outside.dll",
1046                zip::write::SimpleFileOptions::default(),
1047            )
1048            .unwrap();
1049        assert!(extract_library(
1050            &linked.finish().unwrap().into_inner(),
1051            "zip",
1052            "declared.dll"
1053        )
1054        .is_err());
1055        let mut tar = tar::Builder::new(flate2::write::GzEncoder::new(
1056            Vec::new(),
1057            flate2::Compression::default(),
1058        ));
1059        let mut header = tar::Header::new_gnu();
1060        header.set_entry_type(tar::EntryType::Link);
1061        header.set_size(0);
1062        header.set_mode(0o644);
1063        header.set_cksum();
1064        tar.append_link(&mut header, "declared.so", "outside.so")
1065            .unwrap();
1066        assert!(extract_library(
1067            &tar.into_inner().unwrap().finish().unwrap(),
1068            "tar.gz",
1069            "declared.so"
1070        )
1071        .is_err());
1072    }
1073
1074    #[test]
1075    fn object_headers_cannot_cross_platform_or_architecture() {
1076        for target in TARGETS {
1077            assert!(validate_library_target(&object(target), target).is_ok());
1078            for other in TARGETS {
1079                if other != target {
1080                    assert!(validate_library_target(&object(other), target).is_err());
1081                }
1082            }
1083        }
1084        assert!(validate_library_target(&[0u8; 8], TARGETS[0]).is_err());
1085        let mut damaged = object(TARGETS[0]);
1086        damaged[0x3c..0x40].copy_from_slice(&u32::MAX.to_le_bytes());
1087        assert!(validate_library_target(&damaged, TARGETS[0]).is_err());
1088    }
1089
1090    #[test]
1091    fn exact_versions_ignore_yanked_and_prerelease_latest() {
1092        let versions = [
1093            RegistryVersion {
1094                num: "1.0.0".into(),
1095                yanked: false,
1096            },
1097            RegistryVersion {
1098                num: "9.0.0".into(),
1099                yanked: true,
1100            },
1101            RegistryVersion {
1102                num: "2.0.0-beta.1".into(),
1103                yanked: false,
1104            },
1105        ];
1106        assert_eq!(select_version(&versions, None).unwrap(), "1.0.0");
1107        assert_eq!(
1108            select_version(&versions, Some("2.0.0-beta.1")).unwrap(),
1109            "2.0.0-beta.1"
1110        );
1111        assert!(select_version(&versions, Some("9.0.0")).is_err());
1112        assert!(select_version(&versions, Some("^1")).is_err());
1113    }
1114
1115    #[test]
1116    fn github_case_and_encoded_semver_build_metadata_preserve_release_identity() {
1117        let (mut package, mut value, _) = fixture(TARGETS[0]);
1118        package.version = "1.2.3+build.1".into();
1119        value["version"] = serde_json::json!(package.version);
1120        value["tag"] = serde_json::json!("independent-plugin-v1.2.3+build.1");
1121        value["repository"] = serde_json::json!("https://github.com/Publisher/MonoRepo");
1122        value["targets"][TARGETS[0]]["url"] = serde_json::json!("https://github.com/Publisher/MonoRepo/releases/download/independent-plugin-v1.2.3%2Bbuild.1/package.zip");
1123        let manifest = serde_json::from_value(value).unwrap();
1124        assert!(validate_manifest(&manifest, &package, TARGETS[0]).is_ok());
1125        assert_eq!(
1126            canonical_repository("Publisher/MonoRepo").unwrap(),
1127            package.repository
1128        );
1129    }
1130
1131    #[test]
1132    fn repository_urls_are_explicit_and_release_tags_use_package_versions() {
1133        assert_eq!(
1134            canonical_repository("git+https://github.com/publisher/monorepo.git").unwrap(),
1135            "https://github.com/publisher/monorepo"
1136        );
1137        assert_eq!(
1138            canonical_repository("https://github.com/publisher/monorepo/tree/main/packages/plugin")
1139                .unwrap(),
1140            "https://github.com/publisher/monorepo"
1141        );
1142        for invalid in [
1143            "https://gitlab.com/x/y",
1144            "https://user@github.com/x/y",
1145            "https://github.com/x",
1146            "https://github.com/x/y/releases/latest",
1147            "https://github.com:8443/x/y",
1148        ] {
1149            assert!(canonical_repository(invalid).is_err());
1150        }
1151        assert_eq!(release_url("https://github.com/publisher/monorepo","plugin-one","1.2.3","rpi-extension.json").unwrap(),"https://github.com/publisher/monorepo/releases/download/plugin-one-v1.2.3/rpi-extension.json");
1152        assert!(release_url(
1153            "https://github.com/publisher/monorepo",
1154            "plugin-two",
1155            "4.5.6",
1156            "rpi-extension.json"
1157        )
1158        .unwrap()
1159        .contains("plugin-two-v4.5.6"));
1160    }
1161
1162    fn one_response(body: Vec<u8>) -> (String, std::thread::JoinHandle<String>) {
1163        response_with_status("200 OK", body)
1164    }
1165
1166    fn response_with_status(
1167        status: &'static str,
1168        body: Vec<u8>,
1169    ) -> (String, std::thread::JoinHandle<String>) {
1170        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1171        let url = format!("http://{}", listener.local_addr().unwrap());
1172        let thread = std::thread::spawn(move || {
1173            let (mut socket, _) = listener.accept().unwrap();
1174            socket
1175                .set_read_timeout(Some(std::time::Duration::from_secs(5)))
1176                .unwrap();
1177            let mut request = Vec::new();
1178            let mut byte = [0];
1179            while !request.ends_with(b"\r\n\r\n") {
1180                socket.read_exact(&mut byte).unwrap();
1181                request.push(byte[0]);
1182            }
1183            write!(
1184                socket,
1185                "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1186                body.len()
1187            )
1188            .unwrap();
1189            socket.write_all(&body).unwrap();
1190            String::from_utf8(request).unwrap()
1191        });
1192        (url, thread)
1193    }
1194
1195    #[tokio::test]
1196    async fn downloaded_manifest_and_archive_install_into_an_isolated_profile() {
1197        let target = host_target().unwrap();
1198        let (package, value, archive) = fixture(target);
1199        let raw = serde_json::to_vec(&value).unwrap();
1200        let client = reqwest::Client::builder().no_proxy().build().unwrap();
1201        let (url, server) = one_response(raw.clone());
1202        let response = client.get(url).send().await.unwrap();
1203        let (manifest, received) = read_manifest_response(response, &package).await.unwrap();
1204        server.join().unwrap();
1205        assert_eq!(received, raw);
1206        let distribution = validate_manifest(&manifest, &package, target).unwrap();
1207        let (url, server) = one_response(archive);
1208        let response = client.get(url).send().await.unwrap();
1209        let downloaded = body_limited(response, distribution.size).await.unwrap();
1210        server.join().unwrap();
1211        let profile = tempfile::tempdir().unwrap();
1212        let paths = install_archive_at(
1213            &package,
1214            &manifest,
1215            &received,
1216            &downloaded,
1217            target,
1218            profile.path(),
1219            false,
1220        )
1221        .unwrap();
1222        assert_eq!(std::fs::read(&paths[0]).unwrap(), object(target));
1223        assert!(profile.path().join("native-packages.json").is_file());
1224    }
1225
1226    #[tokio::test]
1227    async fn missing_invalid_and_oversized_manifests_fail_before_installation() {
1228        let (package, _, _) = fixture(TARGETS[0]);
1229        let client = reqwest::Client::builder().no_proxy().build().unwrap();
1230        for (status, body, expected) in [
1231            (
1232                "404 Not Found",
1233                b"missing".to_vec(),
1234                "rpi-extension.json is missing",
1235            ),
1236            ("403 Forbidden", b"denied".to_vec(), "403 Forbidden"),
1237            ("200 OK", b"not json".to_vec(), "invalid rpi-extension.json"),
1238            (
1239                "200 OK",
1240                vec![b' '; MAX_MANIFEST as usize + 1],
1241                "size limit",
1242            ),
1243        ] {
1244            let (url, server) = response_with_status(status, body);
1245            let response = client.get(url).send().await.unwrap();
1246            let error = read_manifest_response(response, &package)
1247                .await
1248                .err()
1249                .unwrap();
1250            assert!(error.contains(expected), "{error}");
1251            server.join().unwrap();
1252        }
1253    }
1254
1255    #[tokio::test]
1256    async fn explicit_repository_and_version_do_not_require_a_registry_record() {
1257        let client = reqwest::Client::new();
1258        let resolved = resolve_package(
1259            &client,
1260            "http://127.0.0.1:1",
1261            "private-extension",
1262            Some("2.3.4"),
1263            Some("https://github.com/custom-owner/custom-repo.git"),
1264        )
1265        .await
1266        .unwrap();
1267        assert_eq!(
1268            resolved.repository,
1269            "https://github.com/custom-owner/custom-repo"
1270        );
1271        assert_eq!(resolved.name, "private-extension");
1272        assert_eq!(resolved.version, "2.3.4");
1273        assert!(release_url(
1274            &resolved.repository,
1275            &resolved.name,
1276            &resolved.version,
1277            "rpi-extension.json"
1278        )
1279        .unwrap()
1280        .contains("custom-repo/releases/download/private-extension-v2.3.4/"));
1281    }
1282
1283    #[tokio::test]
1284    async fn custom_repository_versions_are_scoped_to_the_package() {
1285        let refs = serde_json::to_vec(&serde_json::json!([
1286            {"ref":"refs/tags/independent-plugin-v1.2.3"},
1287            {"ref":"refs/tags/independent-plugin-v1.4.0"},
1288            {"ref":"refs/tags/other-plugin-v99.0.0"},
1289            {"ref":"refs/tags/independent-plugin-v2.0.0-beta.1"}
1290        ]))
1291        .unwrap();
1292        let (api, server) = one_response(refs);
1293        let client = reqwest::Client::builder().no_proxy().build().unwrap();
1294        assert_eq!(
1295            repository_version(
1296                &client,
1297                &api,
1298                "independent-plugin",
1299                "https://github.com/publisher/monorepo"
1300            )
1301            .await
1302            .unwrap(),
1303            "1.4.0"
1304        );
1305        assert!(server.join().unwrap().starts_with(
1306            "GET /repos/publisher/monorepo/git/matching-refs/tags/independent-plugin-v "
1307        ));
1308    }
1309
1310    #[tokio::test]
1311    async fn registry_discovery_uses_repository_metadata_and_downloads_are_bounded() {
1312        let payload = serde_json::to_vec(&serde_json::json!({"crate":{"id":"independent-plugin","repository":"https://github.com/publisher/monorepo.git"},"versions":[{"num":"1.2.3","yanked":false},{"num":"3.0.0-beta.1","yanked":false},{"num":"9.0.0","yanked":true}]})).unwrap();
1313        let (url, server) = one_response(payload);
1314        let client = reqwest::Client::builder().no_proxy().build().unwrap();
1315        let package = resolve_package(&client, &url, "independent-plugin", None, None)
1316            .await
1317            .unwrap();
1318        assert_eq!(package.repository, "https://github.com/publisher/monorepo");
1319        assert_eq!(package.version, "1.2.3");
1320        assert!(server
1321            .join()
1322            .unwrap()
1323            .starts_with("GET /independent-plugin "));
1324        let (url, server) = one_response(vec![0u8; 32]);
1325        let response = client.get(url).send().await.unwrap();
1326        assert!(body_limited(response, 8).await.is_err());
1327        server.join().unwrap();
1328    }
1329}