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    let target = host_target()?;
518    let client = reqwest::Client::builder()
519        .timeout(std::time::Duration::from_secs(15))
520        .user_agent(format!("rpi/{}", crate::VERSION))
521        .build()
522        .map_err(|e| e.to_string())?;
523    let repository = repository.or_else(|| {
524        existing
525            .and_then(|p| p.prebuilt.as_ref())
526            .filter(|record| record.custom_repository)
527            .map(|r| r.repository.as_str())
528    });
529    let package = resolve_package(&client, CRATES_API, name, version, repository).await?;
530    if let Some(record) = existing.and_then(|p| p.prebuilt.as_ref()) {
531        if repository.is_none() && record.repository != package.repository {
532            return Err(
533                "extension repository changed; select the new repository explicitly with --github"
534                    .into(),
535            );
536        }
537    }
538    if !crate::install::should_install_version(
539        name,
540        existing.map(|p| p.version.as_str()),
541        &package.version,
542        force,
543    )? {
544        crate::install::print_already_installed(name, &package.version);
545        return Ok(());
546    }
547    println!(
548        "downloading prebuilt {} {} for {target}",
549        package.name, package.version
550    );
551    let (manifest, manifest_bytes) = fetch_manifest(&client, &package).await?;
552    let distribution = validate_manifest(&manifest, &package, target)?;
553    let response = client
554        .get(&distribution.url)
555        .timeout(std::time::Duration::from_secs(180))
556        .send()
557        .await
558        .map_err(|e| format!("archive request failed: {e}"))?;
559    if !response.status().is_success() {
560        return Err(format!("archive request returned {}", response.status()));
561    }
562    let archive = body_limited(response, distribution.size).await?;
563    let agent_dir = crate::config::agent_dir().map_err(|e| e.to_string())?;
564    let target = target.to_string();
565    let installed = tokio::task::spawn_blocking(move || {
566        install_archive_at(
567            &package,
568            &manifest,
569            &manifest_bytes,
570            &archive,
571            &target,
572            &agent_dir,
573            force,
574        )
575    })
576    .await
577    .map_err(|e| format!("prebuilt installation failed: {e}"))??;
578    for path in installed {
579        println!("installed {}", path.display());
580    }
581    println!("rpi will validate the plugin entrypoint/SDK API size and load this extension on the next start.");
582    Ok(())
583}
584
585fn install_archive_at(
586    package: &ResolvedPackage,
587    manifest: &Manifest,
588    manifest_bytes: &[u8],
589    archive: &[u8],
590    target: &str,
591    agent_dir: &Path,
592    force: bool,
593) -> Result<Vec<PathBuf>, String> {
594    let distribution = validate_manifest(manifest, package, target)?;
595    if archive.len() as u64 != distribution.size {
596        return Err("archive byte length does not match manifest".into());
597    }
598    check_hash(archive, &distribution.sha256)?;
599    let bytes = extract_library(archive, &distribution.format, &distribution.library.path)?;
600    check_hash(&bytes, &distribution.library.sha256)?;
601    validate_library_target(&bytes, target)?;
602    let provenance = PrebuiltRecord {
603        repository: package.repository.clone(),
604        target: target.into(),
605        manifest_sha256: hash(manifest_bytes),
606        source_commit: manifest.source_commit.clone(),
607        sdk_version: manifest.sdk.version.clone(),
608        plugin_abi: manifest.sdk.plugin_abi,
609        archive_sha256: distribution.sha256.clone(),
610        library_sha256: distribution.library.sha256.clone(),
611        custom_repository: package.custom_repository,
612    };
613    let temporary = tempfile::tempdir().map_err(|e| e.to_string())?;
614    let artifact = temporary.path().join(&distribution.library.path);
615    std::fs::write(&artifact, bytes).map_err(|e| e.to_string())?;
616    let record = crate::install::InstalledNativePackage {
617        name: package.name.clone(),
618        version: package.version.clone(),
619        // Older hosts regard this as a non-registry source and will not rebuild it.
620        source: Some(format!("prebuilt:{}", package.repository)),
621        prebuilt: Some(provenance),
622        artifacts: Vec::new(),
623    };
624    crate::install::install_native_package_at(
625        &agent_dir.join("extensions"),
626        &agent_dir.join("native-packages.json"),
627        record,
628        &[artifact],
629        force,
630    )
631}
632
633fn safe_entry_name(name: &str) -> Result<PathBuf, String> {
634    if name.contains(['\\', ':'])
635        || Path::new(name).components().any(|p| {
636            matches!(
637                p,
638                Component::ParentDir | Component::RootDir | Component::Prefix(_)
639            )
640        })
641    {
642        return Err("unsafe archive entry path".into());
643    }
644    Ok(Path::new(name)
645        .components()
646        .filter(|c| !matches!(c, Component::CurDir))
647        .collect())
648}
649
650fn read_library(reader: impl Read) -> Result<Vec<u8>, String> {
651    let mut bytes = Vec::new();
652    reader
653        .take(MAX_LIBRARY + 1)
654        .read_to_end(&mut bytes)
655        .map_err(|e| e.to_string())?;
656    if bytes.len() as u64 > MAX_LIBRARY {
657        return Err("extracted library exceeds size limit".into());
658    }
659    Ok(bytes)
660}
661
662pub(crate) fn extract_library(
663    archive: &[u8],
664    format: &str,
665    library: &str,
666) -> Result<Vec<u8>, String> {
667    let mut found = None;
668    let mut total = 0u64;
669    match format {
670        "zip" => {
671            let mut zip = zip::ZipArchive::new(Cursor::new(archive)).map_err(|e| e.to_string())?;
672            if zip.len() > MAX_ENTRIES {
673                return Err("too many archive entries".into());
674            }
675            for i in 0..zip.len() {
676                let file = zip.by_index(i).map_err(|e| e.to_string())?;
677                let path = safe_entry_name(file.name())?;
678                if file
679                    .unix_mode()
680                    .is_some_and(|mode| mode & 0o170000 == 0o120000)
681                {
682                    return Err("archive symlinks are not allowed".into());
683                }
684                total = total
685                    .checked_add(file.size())
686                    .ok_or("archive size overflow")?;
687                if total > MAX_ARCHIVE {
688                    return Err("expanded archive exceeds size limit".into());
689                }
690                if !file.is_dir() && path == Path::new(library) {
691                    if found.is_some() {
692                        return Err("duplicate declared library in archive".into());
693                    }
694                    found = Some(read_library(file)?);
695                }
696            }
697        }
698        "tar.gz" => {
699            let decoder = flate2::read::GzDecoder::new(Cursor::new(archive)).take(MAX_ARCHIVE + 1);
700            let mut tar = tar::Archive::new(decoder);
701            for (i, entry) in tar.entries().map_err(|e| e.to_string())?.enumerate() {
702                if i >= MAX_ENTRIES {
703                    return Err("too many archive entries".into());
704                }
705                let file = entry.map_err(|e| e.to_string())?;
706                let name = file
707                    .path()
708                    .map_err(|e| e.to_string())?
709                    .to_string_lossy()
710                    .into_owned();
711                let path = safe_entry_name(&name)?;
712                let kind = file.header().entry_type();
713                if !kind.is_file() && !kind.is_dir() {
714                    return Err("archive links and special files are not allowed".into());
715                }
716                total = total
717                    .checked_add(file.size())
718                    .ok_or("archive size overflow")?;
719                if total > MAX_ARCHIVE {
720                    return Err("expanded archive exceeds size limit".into());
721                }
722                if kind.is_file() && path == Path::new(library) {
723                    if found.is_some() {
724                        return Err("duplicate declared library in archive".into());
725                    }
726                    found = Some(read_library(file)?);
727                }
728            }
729        }
730        _ => return Err("unsupported prebuilt archive format".into()),
731    }
732    found.ok_or_else(|| format!("archive does not contain the exact declared library {library}"))
733}
734
735/// Verify the native object format/architecture without executing downloaded code.
736fn validate_library_target(bytes: &[u8], target: &str) -> Result<(), String> {
737    validate_native_target(bytes, target, true)
738}
739
740pub(crate) fn validate_executable_target(bytes: &[u8], target: &str) -> Result<(), String> {
741    validate_native_target(bytes, target, false)
742}
743
744fn validate_native_target(bytes: &[u8], target: &str, library: bool) -> Result<(), String> {
745    let u16_at = |n: usize| {
746        bytes
747            .get(n..n.checked_add(2)?)
748            .map(|b| u16::from_le_bytes([b[0], b[1]]))
749    };
750    let u32_at = |n: usize| {
751        bytes
752            .get(n..n.checked_add(4)?)
753            .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
754    };
755    let valid = match target {
756        "x86_64-pc-windows-msvc" => {
757            let offset = u32_at(0x3c).unwrap_or(u32::MAX) as usize;
758            bytes.starts_with(b"MZ")
759                && bytes.get(offset..offset.saturating_add(4)) == Some(b"PE\0\0".as_slice())
760                && u16_at(offset.saturating_add(4)) == Some(0x8664)
761                && u16_at(offset.saturating_add(22)).is_some_and(|flags| {
762                    if library {
763                        flags & 0x2000 != 0
764                    } else {
765                        flags & 0x2000 == 0 && flags & 2 != 0
766                    }
767                })
768        }
769        "x86_64-unknown-linux-gnu" | "aarch64-unknown-linux-gnu" => {
770            bytes.starts_with(b"\x7fELF\x02\x01")
771                && u16_at(16).is_some_and(|kind| kind == 3 || (!library && kind == 2))
772                && u16_at(18)
773                    == Some(if target.starts_with("aarch64") {
774                        183
775                    } else {
776                        62
777                    })
778        }
779        "x86_64-apple-darwin" | "aarch64-apple-darwin" => {
780            u32_at(0) == Some(0xfeedfacf)
781                && u32_at(12) == Some(if library { 6 } else { 2 })
782                && u32_at(4)
783                    == Some(if target.starts_with("aarch64") {
784                        0x0100000c
785                    } else {
786                        0x01000007
787                    })
788        }
789        _ => false,
790    };
791    if !valid {
792        return Err(format!(
793            "declared artifact is not a compatible native {} for {target}",
794            if library {
795                "shared library"
796            } else {
797                "executable"
798            }
799        ));
800    }
801    Ok(())
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807    use std::io::Write;
808
809    const TARGETS: [&str; 5] = [
810        "x86_64-pc-windows-msvc",
811        "x86_64-unknown-linux-gnu",
812        "aarch64-unknown-linux-gnu",
813        "x86_64-apple-darwin",
814        "aarch64-apple-darwin",
815    ];
816
817    fn object(target: &str) -> Vec<u8> {
818        let mut bytes = vec![0u8; 128];
819        if target.contains("windows") {
820            bytes[..2].copy_from_slice(b"MZ");
821            bytes[0x3c..0x40].copy_from_slice(&64u32.to_le_bytes());
822            bytes[64..68].copy_from_slice(b"PE\0\0");
823            bytes[68..70].copy_from_slice(&0x8664u16.to_le_bytes());
824            bytes[86..88].copy_from_slice(&0x2000u16.to_le_bytes());
825        } else if target.contains("linux") {
826            bytes[..6].copy_from_slice(b"\x7fELF\x02\x01");
827            bytes[16..18].copy_from_slice(&3u16.to_le_bytes());
828            bytes[18..20].copy_from_slice(
829                &(if target.starts_with("aarch64") {
830                    183u16
831                } else {
832                    62u16
833                })
834                .to_le_bytes(),
835            );
836        } else {
837            bytes[..4].copy_from_slice(&0xfeedfacfu32.to_le_bytes());
838            bytes[4..8].copy_from_slice(
839                &(if target.starts_with("aarch64") {
840                    0x0100000cu32
841                } else {
842                    0x01000007u32
843                })
844                .to_le_bytes(),
845            );
846            bytes[12..16].copy_from_slice(&6u32.to_le_bytes());
847        }
848        bytes
849    }
850
851    fn zip(files: &[(&str, &[u8])]) -> Vec<u8> {
852        let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
853        for (name, bytes) in files {
854            zip.start_file(*name, zip::write::SimpleFileOptions::default())
855                .unwrap();
856            zip.write_all(bytes).unwrap();
857        }
858        zip.finish().unwrap().into_inner()
859    }
860
861    fn tgz(name: &str, bytes: &[u8]) -> Vec<u8> {
862        let mut tar = tar::Builder::new(flate2::write::GzEncoder::new(
863            Vec::new(),
864            flate2::Compression::default(),
865        ));
866        let mut header = tar::Header::new_gnu();
867        header.set_size(bytes.len() as u64);
868        header.set_mode(0o644);
869        header.set_cksum();
870        tar.append_data(&mut header, name, bytes).unwrap();
871        tar.into_inner().unwrap().finish().unwrap()
872    }
873
874    fn fixture(target: &str) -> (ResolvedPackage, serde_json::Value, Vec<u8>) {
875        let package = ResolvedPackage {
876            name: "independent-plugin".into(),
877            version: "1.2.3".into(),
878            repository: "https://github.com/publisher/monorepo".into(),
879            custom_repository: false,
880        };
881        // The library target deliberately differs from the crate name.
882        let (format, library, file) = if target.contains("windows") {
883            ("zip", "custom_runtime.dll", "package.zip")
884        } else if target.contains("apple") {
885            ("tar.gz", "libcustom_runtime.dylib", "package.tar.gz")
886        } else {
887            ("tar.gz", "libcustom_runtime.so", "package.tar.gz")
888        };
889        let bytes = object(target);
890        let archive = if format == "zip" {
891            zip(&[(library, &bytes)])
892        } else {
893            tgz(library, &bytes)
894        };
895        let manifest = serde_json::json!({
896            "schema_version":1, "name":package.name, "version":package.version,
897            "repository":package.repository, "tag":"independent-plugin-v1.2.3", "source_commit":"a".repeat(40),
898            "sdk":{"version":"0.3.23", "plugin_abi":rpi_plugin_sdk::RPI_PLUGIN_ABI_VERSION_UNIFIED},
899            "targets":{target:{"url":release_url(&package.repository,&package.name,&package.version,file).unwrap(),
900                "format":format,"size":archive.len(),"sha256":hash(&archive),"library":{"path":library,"sha256":hash(&bytes)}}}
901        });
902        (package, manifest, archive)
903    }
904
905    #[test]
906    fn all_five_distributions_install_and_record_exact_provenance() {
907        for target in TARGETS {
908            let (package, value, archive) = fixture(target);
909            let manifest: Manifest = serde_json::from_value(value.clone()).unwrap();
910            let raw = serde_json::to_vec(&value).unwrap();
911            let agent = tempfile::tempdir().unwrap();
912            let paths = install_archive_at(
913                &package,
914                &manifest,
915                &raw,
916                &archive,
917                target,
918                agent.path(),
919                false,
920            )
921            .unwrap();
922            assert_eq!(paths.len(), 1);
923            assert_eq!(std::fs::read(&paths[0]).unwrap(), object(target));
924            let records: Vec<crate::install::InstalledNativePackage> = serde_json::from_slice(
925                &std::fs::read(agent.path().join("native-packages.json")).unwrap(),
926            )
927            .unwrap();
928            assert_eq!(records[0].name, "independent-plugin");
929            assert_eq!(records[0].version, "1.2.3");
930            assert_eq!(
931                records[0].artifacts,
932                [manifest.targets[target].library.path.clone()]
933            );
934            let origin = records[0].prebuilt.as_ref().unwrap();
935            assert_eq!(origin.target, target);
936            assert_eq!(origin.manifest_sha256, hash(&raw));
937            assert_eq!(origin.repository, package.repository);
938            assert_eq!(origin.archive_sha256, hash(&archive));
939            assert_eq!(origin.library_sha256, hash(&object(target)));
940        }
941    }
942
943    #[test]
944    fn prebuilt_packages_upgrade_without_force_and_same_version_is_unchanged() {
945        for target in TARGETS {
946            let (mut package, mut value, archive) = fixture(target);
947            let manifest: Manifest = serde_json::from_value(value.clone()).unwrap();
948            let agent = tempfile::tempdir().unwrap();
949            install_archive_at(
950                &package,
951                &manifest,
952                &serde_json::to_vec(&value).unwrap(),
953                &archive,
954                target,
955                agent.path(),
956                false,
957            )
958            .unwrap();
959            package.version = "1.2.4".into();
960            value["version"] = serde_json::json!(package.version);
961            value["tag"] = serde_json::json!("independent-plugin-v1.2.4");
962            let distribution = value.pointer_mut(&format!("/targets/{target}")).unwrap();
963            let format = distribution["format"].as_str().unwrap();
964            let file = if format == "zip" {
965                "package.zip"
966            } else {
967                "package.tar.gz"
968            };
969            distribution["url"] = serde_json::json!(release_url(
970                &package.repository,
971                &package.name,
972                &package.version,
973                file,
974            )
975            .unwrap());
976            let manifest = serde_json::from_value(value.clone()).unwrap();
977            let raw = serde_json::to_vec(&value).unwrap();
978            assert_eq!(
979                install_archive_at(
980                    &package,
981                    &manifest,
982                    &raw,
983                    &archive,
984                    target,
985                    agent.path(),
986                    false,
987                )
988                .unwrap()
989                .len(),
990                1
991            );
992            let metadata = agent.path().join("native-packages.json");
993            let registry = std::fs::read(&metadata).unwrap();
994            let records: Vec<crate::install::InstalledNativePackage> =
995                serde_json::from_slice(&registry).unwrap();
996            assert_eq!(records[0].version, "1.2.4");
997            assert!(install_archive_at(
998                &package,
999                &manifest,
1000                &raw,
1001                &archive,
1002                target,
1003                agent.path(),
1004                false,
1005            )
1006            .unwrap()
1007            .is_empty());
1008            assert_eq!(std::fs::read(&metadata).unwrap(), registry);
1009        }
1010    }
1011
1012    #[test]
1013    fn bad_release_identity_hash_abi_and_target_leave_installed_version_intact() {
1014        let target = TARGETS[0];
1015        let (package, good, archive) = fixture(target);
1016        let manifest: Manifest = serde_json::from_value(good.clone()).unwrap();
1017        let agent = tempfile::tempdir().unwrap();
1018        let path = install_archive_at(
1019            &package,
1020            &manifest,
1021            &serde_json::to_vec(&good).unwrap(),
1022            &archive,
1023            target,
1024            agent.path(),
1025            false,
1026        )
1027        .unwrap()
1028        .remove(0);
1029        let original_record = std::fs::read(agent.path().join("native-packages.json")).unwrap();
1030        let mutations = [
1031            ("/schema_version",serde_json::json!(99)), ("/name",serde_json::json!("other-plugin")),
1032            ("/version",serde_json::json!("9.9.9")), ("/tag",serde_json::json!("v1.2.3")),
1033            ("/repository",serde_json::json!("https://github.com/other/repo")),
1034            ("/sdk/plugin_abi",serde_json::json!(99)),
1035            ("/targets/x86_64-pc-windows-msvc/sha256",serde_json::json!("0".repeat(64))),
1036            ("/targets/x86_64-pc-windows-msvc/library/sha256",serde_json::json!("0".repeat(64))),
1037            ("/targets/x86_64-pc-windows-msvc/library/path",serde_json::json!("../custom_runtime.dll")),
1038            ("/targets/x86_64-pc-windows-msvc/url",serde_json::json!("https://github.com/other/repo/releases/download/independent-plugin-v1.2.3/package.zip")),
1039            ("/targets/x86_64-pc-windows-msvc/size",serde_json::json!(archive.len()+1)),
1040        ];
1041        for (pointer, value) in mutations {
1042            let mut bad = good.clone();
1043            *bad.pointer_mut(pointer).unwrap() = value;
1044            let bad_manifest = serde_json::from_value(bad.clone()).unwrap();
1045            assert!(
1046                install_archive_at(
1047                    &package,
1048                    &bad_manifest,
1049                    &serde_json::to_vec(&bad).unwrap(),
1050                    &archive,
1051                    target,
1052                    agent.path(),
1053                    true
1054                )
1055                .is_err(),
1056                "{pointer}"
1057            );
1058            assert_eq!(std::fs::read(&path).unwrap(), object(target));
1059            assert_eq!(
1060                std::fs::read(agent.path().join("native-packages.json")).unwrap(),
1061                original_record
1062            );
1063        }
1064        assert!(install_archive_at(
1065            &package,
1066            &manifest,
1067            &[],
1068            &archive,
1069            TARGETS[1],
1070            agent.path(),
1071            true
1072        )
1073        .is_err());
1074    }
1075
1076    #[test]
1077    fn same_abi_but_wrong_binary_architecture_is_rejected_before_activation() {
1078        let (package, mut value, _) = fixture(TARGETS[0]);
1079        let wrong = object(TARGETS[2]);
1080        let archive = zip(&[("custom_runtime.dll", &wrong)]);
1081        let distribution = value
1082            .pointer_mut("/targets/x86_64-pc-windows-msvc")
1083            .unwrap();
1084        distribution["size"] = serde_json::json!(archive.len());
1085        distribution["sha256"] = serde_json::json!(hash(&archive));
1086        distribution["library"]["sha256"] = serde_json::json!(hash(&wrong));
1087        let manifest = serde_json::from_value(value).unwrap();
1088        let agent = tempfile::tempdir().unwrap();
1089        assert!(install_archive_at(
1090            &package,
1091            &manifest,
1092            &[],
1093            &archive,
1094            TARGETS[0],
1095            agent.path(),
1096            false
1097        )
1098        .unwrap_err()
1099        .contains("shared library"));
1100        assert!(!agent.path().join("native-packages.json").exists());
1101        assert!(!agent.path().join("extensions").exists());
1102    }
1103
1104    #[test]
1105    fn archives_cannot_traverse_retain_links_or_substitute_another_library() {
1106        let library = object(TARGETS[0]);
1107        assert!(
1108            extract_library(&zip(&[("../escape.dll", &library)]), "zip", "escape.dll").is_err()
1109        );
1110        assert!(
1111            extract_library(&zip(&[("C:\\escape.dll", &library)]), "zip", "escape.dll").is_err()
1112        );
1113        assert!(extract_library(&zip(&[("other.dll", &library)]), "zip", "declared.dll").is_err());
1114        let mut linked = zip::ZipWriter::new(Cursor::new(Vec::new()));
1115        linked
1116            .add_symlink(
1117                "declared.dll",
1118                "outside.dll",
1119                zip::write::SimpleFileOptions::default(),
1120            )
1121            .unwrap();
1122        assert!(extract_library(
1123            &linked.finish().unwrap().into_inner(),
1124            "zip",
1125            "declared.dll"
1126        )
1127        .is_err());
1128        let mut tar = tar::Builder::new(flate2::write::GzEncoder::new(
1129            Vec::new(),
1130            flate2::Compression::default(),
1131        ));
1132        let mut header = tar::Header::new_gnu();
1133        header.set_entry_type(tar::EntryType::Link);
1134        header.set_size(0);
1135        header.set_mode(0o644);
1136        header.set_cksum();
1137        tar.append_link(&mut header, "declared.so", "outside.so")
1138            .unwrap();
1139        assert!(extract_library(
1140            &tar.into_inner().unwrap().finish().unwrap(),
1141            "tar.gz",
1142            "declared.so"
1143        )
1144        .is_err());
1145    }
1146
1147    #[test]
1148    fn object_headers_cannot_cross_platform_or_architecture() {
1149        for target in TARGETS {
1150            assert!(validate_library_target(&object(target), target).is_ok());
1151            for other in TARGETS {
1152                if other != target {
1153                    assert!(validate_library_target(&object(other), target).is_err());
1154                }
1155            }
1156        }
1157        assert!(validate_library_target(&[0u8; 8], TARGETS[0]).is_err());
1158        let mut damaged = object(TARGETS[0]);
1159        damaged[0x3c..0x40].copy_from_slice(&u32::MAX.to_le_bytes());
1160        assert!(validate_library_target(&damaged, TARGETS[0]).is_err());
1161    }
1162
1163    #[test]
1164    fn exact_versions_ignore_yanked_and_prerelease_latest() {
1165        let versions = [
1166            RegistryVersion {
1167                num: "1.0.0".into(),
1168                yanked: false,
1169            },
1170            RegistryVersion {
1171                num: "9.0.0".into(),
1172                yanked: true,
1173            },
1174            RegistryVersion {
1175                num: "2.0.0-beta.1".into(),
1176                yanked: false,
1177            },
1178        ];
1179        assert_eq!(select_version(&versions, None).unwrap(), "1.0.0");
1180        assert_eq!(
1181            select_version(&versions, Some("2.0.0-beta.1")).unwrap(),
1182            "2.0.0-beta.1"
1183        );
1184        assert!(select_version(&versions, Some("9.0.0")).is_err());
1185        assert!(select_version(&versions, Some("^1")).is_err());
1186    }
1187
1188    #[test]
1189    fn github_case_and_encoded_semver_build_metadata_preserve_release_identity() {
1190        let (mut package, mut value, _) = fixture(TARGETS[0]);
1191        package.version = "1.2.3+build.1".into();
1192        value["version"] = serde_json::json!(package.version);
1193        value["tag"] = serde_json::json!("independent-plugin-v1.2.3+build.1");
1194        value["repository"] = serde_json::json!("https://github.com/Publisher/MonoRepo");
1195        value["targets"][TARGETS[0]]["url"] = serde_json::json!("https://github.com/Publisher/MonoRepo/releases/download/independent-plugin-v1.2.3%2Bbuild.1/package.zip");
1196        let manifest = serde_json::from_value(value).unwrap();
1197        assert!(validate_manifest(&manifest, &package, TARGETS[0]).is_ok());
1198        assert_eq!(
1199            canonical_repository("Publisher/MonoRepo").unwrap(),
1200            package.repository
1201        );
1202    }
1203
1204    #[test]
1205    fn repository_urls_are_explicit_and_release_tags_use_package_versions() {
1206        assert_eq!(
1207            canonical_repository("git+https://github.com/publisher/monorepo.git").unwrap(),
1208            "https://github.com/publisher/monorepo"
1209        );
1210        assert_eq!(
1211            canonical_repository("https://github.com/publisher/monorepo/tree/main/packages/plugin")
1212                .unwrap(),
1213            "https://github.com/publisher/monorepo"
1214        );
1215        for invalid in [
1216            "https://gitlab.com/x/y",
1217            "https://user@github.com/x/y",
1218            "https://github.com/x",
1219            "https://github.com/x/y/releases/latest",
1220            "https://github.com:8443/x/y",
1221        ] {
1222            assert!(canonical_repository(invalid).is_err());
1223        }
1224        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");
1225        assert!(release_url(
1226            "https://github.com/publisher/monorepo",
1227            "plugin-two",
1228            "4.5.6",
1229            "rpi-extension.json"
1230        )
1231        .unwrap()
1232        .contains("plugin-two-v4.5.6"));
1233    }
1234
1235    fn one_response(body: Vec<u8>) -> (String, std::thread::JoinHandle<String>) {
1236        response_with_status("200 OK", body)
1237    }
1238
1239    fn response_with_status(
1240        status: &'static str,
1241        body: Vec<u8>,
1242    ) -> (String, std::thread::JoinHandle<String>) {
1243        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1244        let url = format!("http://{}", listener.local_addr().unwrap());
1245        let thread = std::thread::spawn(move || {
1246            let (mut socket, _) = listener.accept().unwrap();
1247            socket
1248                .set_read_timeout(Some(std::time::Duration::from_secs(5)))
1249                .unwrap();
1250            let mut request = Vec::new();
1251            let mut byte = [0];
1252            while !request.ends_with(b"\r\n\r\n") {
1253                socket.read_exact(&mut byte).unwrap();
1254                request.push(byte[0]);
1255            }
1256            write!(
1257                socket,
1258                "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1259                body.len()
1260            )
1261            .unwrap();
1262            socket.write_all(&body).unwrap();
1263            String::from_utf8(request).unwrap()
1264        });
1265        (url, thread)
1266    }
1267
1268    #[tokio::test]
1269    async fn downloaded_manifest_and_archive_install_into_an_isolated_profile() {
1270        let target = host_target().unwrap();
1271        let (package, value, archive) = fixture(target);
1272        let raw = serde_json::to_vec(&value).unwrap();
1273        let client = reqwest::Client::builder().no_proxy().build().unwrap();
1274        let (url, server) = one_response(raw.clone());
1275        let response = client.get(url).send().await.unwrap();
1276        let (manifest, received) = read_manifest_response(response, &package).await.unwrap();
1277        server.join().unwrap();
1278        assert_eq!(received, raw);
1279        let distribution = validate_manifest(&manifest, &package, target).unwrap();
1280        let (url, server) = one_response(archive);
1281        let response = client.get(url).send().await.unwrap();
1282        let downloaded = body_limited(response, distribution.size).await.unwrap();
1283        server.join().unwrap();
1284        let profile = tempfile::tempdir().unwrap();
1285        let paths = install_archive_at(
1286            &package,
1287            &manifest,
1288            &received,
1289            &downloaded,
1290            target,
1291            profile.path(),
1292            false,
1293        )
1294        .unwrap();
1295        assert_eq!(std::fs::read(&paths[0]).unwrap(), object(target));
1296        assert!(profile.path().join("native-packages.json").is_file());
1297    }
1298
1299    #[tokio::test]
1300    async fn missing_invalid_and_oversized_manifests_fail_before_installation() {
1301        let (package, _, _) = fixture(TARGETS[0]);
1302        let client = reqwest::Client::builder().no_proxy().build().unwrap();
1303        for (status, body, expected) in [
1304            (
1305                "404 Not Found",
1306                b"missing".to_vec(),
1307                "rpi-extension.json is missing",
1308            ),
1309            ("403 Forbidden", b"denied".to_vec(), "403 Forbidden"),
1310            ("200 OK", b"not json".to_vec(), "invalid rpi-extension.json"),
1311            (
1312                "200 OK",
1313                vec![b' '; MAX_MANIFEST as usize + 1],
1314                "size limit",
1315            ),
1316        ] {
1317            let (url, server) = response_with_status(status, body);
1318            let response = client.get(url).send().await.unwrap();
1319            let error = read_manifest_response(response, &package)
1320                .await
1321                .err()
1322                .unwrap();
1323            assert!(error.contains(expected), "{error}");
1324            server.join().unwrap();
1325        }
1326    }
1327
1328    #[tokio::test]
1329    async fn explicit_repository_and_version_do_not_require_a_registry_record() {
1330        let client = reqwest::Client::new();
1331        let resolved = resolve_package(
1332            &client,
1333            "http://127.0.0.1:1",
1334            "private-extension",
1335            Some("2.3.4"),
1336            Some("https://github.com/custom-owner/custom-repo.git"),
1337        )
1338        .await
1339        .unwrap();
1340        assert_eq!(
1341            resolved.repository,
1342            "https://github.com/custom-owner/custom-repo"
1343        );
1344        assert_eq!(resolved.name, "private-extension");
1345        assert_eq!(resolved.version, "2.3.4");
1346        assert!(release_url(
1347            &resolved.repository,
1348            &resolved.name,
1349            &resolved.version,
1350            "rpi-extension.json"
1351        )
1352        .unwrap()
1353        .contains("custom-repo/releases/download/private-extension-v2.3.4/"));
1354    }
1355
1356    #[tokio::test]
1357    async fn custom_repository_versions_are_scoped_to_the_package() {
1358        let refs = serde_json::to_vec(&serde_json::json!([
1359            {"ref":"refs/tags/independent-plugin-v1.2.3"},
1360            {"ref":"refs/tags/independent-plugin-v1.4.0"},
1361            {"ref":"refs/tags/other-plugin-v99.0.0"},
1362            {"ref":"refs/tags/independent-plugin-v2.0.0-beta.1"}
1363        ]))
1364        .unwrap();
1365        let (api, server) = one_response(refs);
1366        let client = reqwest::Client::builder().no_proxy().build().unwrap();
1367        assert_eq!(
1368            repository_version(
1369                &client,
1370                &api,
1371                "independent-plugin",
1372                "https://github.com/publisher/monorepo"
1373            )
1374            .await
1375            .unwrap(),
1376            "1.4.0"
1377        );
1378        assert!(server.join().unwrap().starts_with(
1379            "GET /repos/publisher/monorepo/git/matching-refs/tags/independent-plugin-v "
1380        ));
1381    }
1382
1383    #[tokio::test]
1384    async fn registry_discovery_uses_repository_metadata_and_downloads_are_bounded() {
1385        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();
1386        let (url, server) = one_response(payload);
1387        let client = reqwest::Client::builder().no_proxy().build().unwrap();
1388        let package = resolve_package(&client, &url, "independent-plugin", None, None)
1389            .await
1390            .unwrap();
1391        assert_eq!(package.repository, "https://github.com/publisher/monorepo");
1392        assert_eq!(package.version, "1.2.3");
1393        assert!(server
1394            .join()
1395            .unwrap()
1396            .starts_with("GET /independent-plugin "));
1397        let (url, server) = one_response(vec![0u8; 32]);
1398        let response = client.get(url).send().await.unwrap();
1399        assert!(body_limited(response, 8).await.is_err());
1400        server.join().unwrap();
1401    }
1402}