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/// Optional archive index for sidecar files beside the library. Archives
19/// without it are the legacy single-library layout.
20const RUNTIME_ASSETS_FILE: &str = "runtime-assets.json";
21/// Largest accepted `runtime-assets.json`, in bytes.
22const MAX_ASSET_INDEX: u64 = 256 * 1024;
23/// Longest accepted sidecar path, in bytes.
24const MAX_ASSET_PATH: usize = 512;
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct PrebuiltRecord {
28    pub repository: String,
29    pub target: String,
30    pub manifest_sha256: String,
31    pub source_commit: String,
32    pub sdk_version: String,
33    pub plugin_abi: u32,
34    pub archive_sha256: String,
35    pub library_sha256: String,
36    /// Explicit repository installs discover versions from package-specific tags.
37    #[serde(default)]
38    pub custom_repository: bool,
39    /// Sidecar files installed beside the library, as verified against the
40    /// archive index. Empty for single-library archives, so their records are unchanged.
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub assets: Vec<PrebuiltAsset>,
43}
44
45/// One verified sidecar file, relative to the extension store directory.
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct PrebuiltAsset {
48    pub path: String,
49    pub sha256: String,
50}
51
52/// The sidecar index inside a package archive: `runtime-assets.json`.
53#[derive(Debug, Deserialize)]
54struct RuntimeAssetIndex {
55    schema_version: u32,
56    assets: Vec<RuntimeAsset>,
57}
58
59#[derive(Debug, Deserialize)]
60struct RuntimeAsset {
61    path: String,
62    size: u64,
63    sha256: String,
64}
65
66/// Size caps for one archive: each file, and every expanded byte together.
67#[derive(Debug, Clone, Copy)]
68pub(crate) struct ArchiveLimits {
69    pub per_file: u64,
70    pub total: u64,
71}
72
73impl ArchiveLimits {
74    pub(crate) const PREBUILT: Self = Self {
75        per_file: MAX_LIBRARY,
76        total: MAX_ARCHIVE,
77    };
78}
79
80/// A sidecar whose bytes match the archive index.
81pub(crate) struct VerifiedAsset {
82    pub path: String,
83    pub sha256: String,
84    pub bytes: Vec<u8>,
85}
86
87/// Verified archive contents. `assets` follow the order of the archive index.
88pub(crate) struct PrebuiltContents {
89    pub library: Vec<u8>,
90    pub assets: Vec<VerifiedAsset>,
91}
92
93#[derive(Debug, Deserialize)]
94struct RegistryPackage {
95    #[serde(rename = "crate")]
96    metadata: RegistryMetadata,
97    versions: Vec<RegistryVersion>,
98}
99
100#[derive(Debug, Deserialize)]
101struct RegistryMetadata {
102    id: String,
103    repository: Option<String>,
104}
105
106#[derive(Debug, Deserialize)]
107struct RegistryVersion {
108    num: String,
109    yanked: bool,
110}
111
112#[derive(Debug, Deserialize)]
113struct Manifest {
114    schema_version: u32,
115    name: String,
116    version: String,
117    repository: String,
118    tag: String,
119    source_commit: String,
120    sdk: Sdk,
121    targets: BTreeMap<String, Distribution>,
122}
123
124#[derive(Debug, Deserialize)]
125struct Sdk {
126    version: String,
127    plugin_abi: u32,
128}
129
130#[derive(Debug, Deserialize)]
131struct Distribution {
132    url: String,
133    format: String,
134    size: u64,
135    sha256: String,
136    library: Library,
137}
138
139#[derive(Debug, Deserialize)]
140struct Library {
141    path: String,
142    sha256: String,
143}
144
145struct ResolvedPackage {
146    name: String,
147    version: String,
148    repository: String,
149    custom_repository: bool,
150}
151
152#[derive(Deserialize)]
153struct GitReference {
154    #[serde(rename = "ref")]
155    reference: String,
156}
157
158fn stable_tag_version(refs: &[GitReference], name: &str) -> Result<String, String> {
159    let prefix = format!("refs/tags/{name}-v");
160    refs.iter().filter_map(|r| r.reference.strip_prefix(&prefix))
161        .filter_map(|v| semver::Version::parse(v).ok()).filter(|v| v.pre.is_empty())
162        .max().map(|v| v.to_string()).ok_or_else(|| format!("no stable {name}-v<version> tag exists in this repository; specify --version explicitly"))
163}
164
165async fn repository_version(
166    client: &reqwest::Client,
167    api: &str,
168    name: &str,
169    repository: &str,
170) -> Result<String, String> {
171    let repository = canonical_repository(repository)?;
172    let repo = repository
173        .strip_prefix("https://github.com/")
174        .ok_or("invalid GitHub repository")?;
175    let response = client
176        .get(format!(
177            "{api}/repos/{repo}/git/matching-refs/tags/{name}-v"
178        ))
179        .header(reqwest::header::ACCEPT, "application/vnd.github+json")
180        .send()
181        .await
182        .map_err(|e| format!("GitHub tag lookup failed: {e}"))?;
183    if !response.status().is_success() {
184        return Err(format!("GitHub tag lookup returned {}", response.status()));
185    }
186    let refs: Vec<GitReference> =
187        serde_json::from_slice(&body_limited(response, MAX_METADATA).await?)
188            .map_err(|e| format!("invalid GitHub tag metadata: {e}"))?;
189    stable_tag_version(&refs, name)
190}
191
192/// Supported triples are deliberately exact: GNU/musl and MSVC/GNU are
193/// different native-library distributions.
194pub fn host_target() -> Result<&'static str, String> {
195    match (std::env::consts::OS, std::env::consts::ARCH) {
196        ("windows", "x86_64") if cfg!(target_env = "msvc") => Ok("x86_64-pc-windows-msvc"),
197        ("linux", "x86_64") if cfg!(target_env = "gnu") => Ok("x86_64-unknown-linux-gnu"),
198        ("linux", "x86_64") if cfg!(target_env = "musl") => Ok("x86_64-unknown-linux-musl"),
199        ("linux", "aarch64") if cfg!(target_env = "gnu") => Ok("aarch64-unknown-linux-gnu"),
200        ("macos", "x86_64") => Ok("x86_64-apple-darwin"),
201        ("macos", "aarch64") => Ok("aarch64-apple-darwin"),
202        _ => Err(
203            "no supported prebuilt target for this host; use --build to compile explicitly".into(),
204        ),
205    }
206}
207
208fn valid_segment(value: &str) -> bool {
209    !value.is_empty()
210        && !matches!(value, "." | "..")
211        && value
212            .bytes()
213            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
214}
215
216/// Accept an explicit owner/repo override or repository metadata URLs,
217/// including Cargo's common .git / git+https forms and GitHub tree subpaths.
218pub(crate) fn canonical_repository(value: &str) -> Result<String, String> {
219    let value = value.trim().strip_prefix("git+").unwrap_or(value.trim());
220    let value = if value.contains("://") {
221        value.to_string()
222    } else {
223        format!("https://github.com/{value}")
224    };
225    let url = reqwest::Url::parse(&value).map_err(|_| "invalid GitHub repository".to_string())?;
226    if url.scheme() != "https"
227        || url.host_str() != Some("github.com")
228        || !url.username().is_empty()
229        || url.password().is_some()
230        || url.port().is_some()
231    {
232        return Err("prebuilt repository must be an HTTPS GitHub repository; use --build for other repositories".into());
233    }
234    let segments: Vec<_> = url
235        .path_segments()
236        .ok_or("invalid GitHub repository path")?
237        .collect();
238    if segments.len() < 2 || !valid_segment(segments[0]) {
239        return Err("repository must identify a GitHub owner and repository".into());
240    }
241    let repo = segments[1].strip_suffix(".git").unwrap_or(segments[1]);
242    if !valid_segment(repo) {
243        return Err("invalid GitHub repository name".into());
244    }
245    if segments.len() > 2
246        && !segments[2..].iter().all(|s| s.is_empty())
247        && !matches!(segments[2], "tree" | "blob")
248    {
249        return Err("unsupported GitHub repository URL path".into());
250    }
251    Ok(format!(
252        "https://github.com/{}/{}",
253        segments[0].to_ascii_lowercase(),
254        repo.to_ascii_lowercase()
255    ))
256}
257
258fn release_url(repository: &str, name: &str, version: &str, file: &str) -> Result<String, String> {
259    let mut url = reqwest::Url::parse(repository).map_err(|e| e.to_string())?;
260    url.path_segments_mut()
261        .map_err(|_| "invalid release repository")?
262        .push("releases")
263        .push("download")
264        .push(&format!("{name}-v{version}"))
265        .push(file);
266    Ok(url.into())
267}
268
269fn select_version(versions: &[RegistryVersion], requested: Option<&str>) -> Result<String, String> {
270    if let Some(requested) = requested {
271        let version = semver::Version::parse(requested)
272            .map_err(|_| "--version requires an exact semantic version".to_string())?;
273        return versions
274            .iter()
275            .find(|v| !v.yanked && v.num == version.to_string())
276            .map(|v| v.num.clone())
277            .ok_or_else(|| format!("version {requested} is absent or yanked on crates.io"));
278    }
279    versions
280        .iter()
281        .filter(|v| !v.yanked)
282        .filter_map(|v| semver::Version::parse(&v.num).ok())
283        .filter(|v| v.pre.is_empty())
284        .max()
285        .map(|v| v.to_string())
286        .ok_or_else(|| "no non-yanked stable version is available".into())
287}
288
289pub(crate) async fn body_limited(
290    mut response: reqwest::Response,
291    limit: u64,
292) -> Result<Vec<u8>, String> {
293    if response.content_length().is_some_and(|n| n > limit) {
294        return Err("download exceeds size limit".into());
295    }
296    let mut bytes = Vec::new();
297    while let Some(chunk) = response
298        .chunk()
299        .await
300        .map_err(|e| format!("download failed: {e}"))?
301    {
302        if bytes.len() as u64 + chunk.len() as u64 > limit {
303            return Err("download exceeds size limit".into());
304        }
305        bytes.extend_from_slice(&chunk);
306    }
307    Ok(bytes)
308}
309
310async fn resolve_package(
311    client: &reqwest::Client,
312    api: &str,
313    name: &str,
314    version: Option<&str>,
315    repository: Option<&str>,
316) -> Result<ResolvedPackage, String> {
317    // An explicit repository + version also supports unpublished third-party extensions.
318    if let Some(repository) = repository {
319        let repository = canonical_repository(repository)?;
320        let version = match version {
321            Some(version) => semver::Version::parse(version)
322                .map_err(|_| "--version requires an exact semantic version".to_string())?
323                .to_string(),
324            None => repository_version(client, "https://api.github.com", name, &repository).await?,
325        };
326        return Ok(ResolvedPackage {
327            name: name.into(),
328            version,
329            repository,
330            custom_repository: true,
331        });
332    }
333    let response = client
334        .get(format!("{api}/{name}"))
335        .send()
336        .await
337        .map_err(|e| format!("crates.io lookup failed: {e}"))?;
338    if !response.status().is_success() {
339        return Err(format!(
340            "crates.io lookup for {name} returned {}",
341            response.status()
342        ));
343    }
344    let data: RegistryPackage =
345        serde_json::from_slice(&body_limited(response, MAX_METADATA).await?)
346            .map_err(|e| format!("invalid crates.io metadata: {e}"))?;
347    if !data
348        .metadata
349        .id
350        .replace('-', "_")
351        .eq_ignore_ascii_case(&name.replace('-', "_"))
352    {
353        return Err("crates.io metadata identifies a different package".into());
354    }
355    let version = select_version(&data.versions, version)?;
356    let repository = repository
357        .or(data.metadata.repository.as_deref())
358        .ok_or("crate has no repository metadata; use --github owner/repo or --build")?;
359    Ok(ResolvedPackage {
360        name: data.metadata.id,
361        version,
362        repository: canonical_repository(repository)?,
363        custom_repository: false,
364    })
365}
366
367pub(crate) fn hash(bytes: &[u8]) -> String {
368    format!("{:x}", Sha256::digest(bytes))
369}
370
371pub(crate) fn check_hash(bytes: &[u8], expected: &str) -> Result<(), String> {
372    if expected.len() != 64
373        || !expected
374            .bytes()
375            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
376    {
377        return Err("manifest SHA-256 must be 64 lowercase hexadecimal characters".into());
378    }
379    if hash(bytes) != expected {
380        return Err("SHA-256 mismatch".into());
381    }
382    Ok(())
383}
384
385fn validate_manifest<'a>(
386    manifest: &'a Manifest,
387    package: &ResolvedPackage,
388    target: &str,
389) -> Result<&'a Distribution, String> {
390    if manifest.schema_version != 1 {
391        return Err(format!(
392            "unsupported prebuilt manifest schema {}",
393            manifest.schema_version
394        ));
395    }
396    if manifest.name != package.name
397        || manifest.version != package.version
398        || manifest.tag != format!("{}-v{}", package.name, package.version)
399        || canonical_repository(&manifest.repository)? != package.repository
400    {
401        return Err("prebuilt manifest package, version, repository or tag does not match the requested package".into());
402    }
403    if manifest.source_commit.len() != 40
404        || !manifest
405            .source_commit
406            .bytes()
407            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
408    {
409        return Err("manifest source_commit must be a full Git commit SHA".into());
410    }
411    semver::Version::parse(&manifest.sdk.version).map_err(|_| "invalid SDK version in manifest")?;
412    if manifest.sdk.plugin_abi != rpi_plugin_sdk::RPI_PLUGIN_ABI_VERSION_UNIFIED {
413        return Err(format!(
414            "extension SDK ABI {} is incompatible with host ABI {}",
415            manifest.sdk.plugin_abi,
416            rpi_plugin_sdk::RPI_PLUGIN_ABI_VERSION_UNIFIED
417        ));
418    }
419    let distribution = manifest.targets.get(target).ok_or_else(|| {
420        format!(
421            "no prebuilt extension for exact target {target}; use --build to compile explicitly"
422        )
423    })?;
424    if distribution.size == 0
425        || distribution.size > MAX_ARCHIVE
426        || !matches!(distribution.format.as_str(), "zip" | "tar.gz")
427    {
428        return Err("unsupported archive format or size in manifest".into());
429    }
430    let name = &distribution.library.path;
431    let extension = if target.contains("windows") {
432        ".dll"
433    } else if target.contains("apple") {
434        ".dylib"
435    } else {
436        ".so"
437    };
438    if name.is_empty()
439        || !name.ends_with(extension)
440        || name.contains(['/', '\\', ':'])
441        || !name
442            .bytes()
443            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
444        || matches!(name.as_str(), "." | "..")
445    {
446        return Err(
447            "manifest library.path must be a flat library filename for the selected target".into(),
448        );
449    }
450    for expected in [&distribution.sha256, &distribution.library.sha256] {
451        if expected.len() != 64
452            || !expected
453                .bytes()
454                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
455        {
456            return Err("invalid SHA-256 in manifest".into());
457        }
458    }
459    validate_release_asset_url(&distribution.url, &package.repository, &manifest.tag)?;
460    Ok(distribution)
461}
462
463pub(crate) fn validate_release_asset_url(
464    value: &str,
465    repository: &str,
466    tag: &str,
467) -> Result<(), String> {
468    let url = reqwest::Url::parse(value).map_err(|_| "invalid archive URL")?;
469    let segments: Vec<String> = url
470        .path_segments()
471        .ok_or("archive URL has no path")?
472        .map(|s| {
473            percent_encoding::percent_decode_str(s)
474                .decode_utf8()
475                .map(|s| s.into_owned())
476                .map_err(|_| "invalid encoded archive URL")
477        })
478        .collect::<Result<_, _>>()?;
479    if url.scheme() != "https"
480        || url.host_str() != Some("github.com")
481        || url.port().is_some()
482        || !url.username().is_empty()
483        || url.password().is_some()
484        || url.query().is_some()
485        || url.fragment().is_some()
486        || segments.len() != 6
487        || segments[2] != "releases"
488        || segments[3] != "download"
489        || segments[4] != tag
490        || canonical_repository(&format!(
491            "https://github.com/{}/{}",
492            segments[0], segments[1]
493        ))? != repository
494        || segments[5].is_empty()
495        || matches!(segments[5].as_str(), "." | "..")
496        || !segments[5]
497            .bytes()
498            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'+'))
499    {
500        return Err(
501            "archive URL must belong to the same GitHub repository and package Release".into(),
502        );
503    }
504    Ok(())
505}
506
507async fn fetch_manifest(
508    client: &reqwest::Client,
509    package: &ResolvedPackage,
510) -> Result<(Manifest, Vec<u8>), String> {
511    let url = release_url(
512        &package.repository,
513        &package.name,
514        &package.version,
515        "rpi-extension.json",
516    )?;
517    let response = client
518        .get(&url)
519        .send()
520        .await
521        .map_err(|e| format!("prebuilt manifest request failed: {e}"))?;
522    read_manifest_response(response, package).await
523}
524
525async fn read_manifest_response(
526    response: reqwest::Response,
527    package: &ResolvedPackage,
528) -> Result<(Manifest, Vec<u8>), String> {
529    if response.status() == reqwest::StatusCode::NOT_FOUND {
530        return Err(format!("prebuilt release for {} {} is not ready (rpi-extension.json is missing); retry later or use --build explicitly", package.name, package.version));
531    }
532    if !response.status().is_success() {
533        return Err(format!(
534            "prebuilt manifest request returned {}",
535            response.status()
536        ));
537    }
538    let bytes = body_limited(response, MAX_MANIFEST).await?;
539    let manifest =
540        serde_json::from_slice(&bytes).map_err(|e| format!("invalid rpi-extension.json: {e}"))?;
541    Ok((manifest, bytes))
542}
543
544/// Only advertise an update when its exact platform manifest is available and compatible.
545pub async fn latest_compatible_version(
546    client: &reqwest::Client,
547    package: &crate::install::InstalledNativePackage,
548) -> Result<Option<String>, String> {
549    let Some(record) = &package.prebuilt else {
550        return Ok(None);
551    };
552    let repository = record
553        .custom_repository
554        .then_some(record.repository.as_str());
555    let resolved = resolve_package(client, CRATES_API, &package.name, None, repository).await?;
556    if resolved.repository != record.repository {
557        return Err(
558            "extension repository changed; reinstall explicitly to select a new publisher".into(),
559        );
560    }
561    if !crate::updates::is_newer(&package.version, &resolved.version) {
562        return Ok(Some(resolved.version));
563    }
564    let (manifest, _) = fetch_manifest(client, &resolved).await?;
565    validate_manifest(&manifest, &resolved, host_target()?)?;
566    Ok(Some(resolved.version))
567}
568
569pub async fn install(
570    name: &str,
571    version: Option<&str>,
572    repository: Option<&str>,
573    force: bool,
574    cdn_base: Option<&reqwest::Url>,
575) -> Result<(), String> {
576    let records = crate::install::installed_native_packages_strict()?;
577    let existing = records.iter().find(|p| p.name == name);
578    let target = host_target()?;
579    let client = reqwest::Client::builder()
580        .timeout(std::time::Duration::from_secs(15))
581        .user_agent(format!("rpi/{}", crate::VERSION))
582        .build()
583        .map_err(|e| e.to_string())?;
584    let repository = repository.or_else(|| {
585        existing
586            .and_then(|p| p.prebuilt.as_ref())
587            .filter(|record| record.custom_repository)
588            .map(|r| r.repository.as_str())
589    });
590    let package = resolve_package(&client, CRATES_API, name, version, repository).await?;
591    if let Some(record) = existing.and_then(|p| p.prebuilt.as_ref()) {
592        if repository.is_none() && record.repository != package.repository {
593            return Err(
594                "extension repository changed; select the new repository explicitly with --github"
595                    .into(),
596            );
597        }
598    }
599    if !crate::install::should_install_version(
600        name,
601        existing.map(|p| p.version.as_str()),
602        &package.version,
603        force,
604    )? {
605        crate::install::print_already_installed(name, &package.version);
606        return Ok(());
607    }
608    println!(
609        "downloading prebuilt {} {} for {target}",
610        package.name, package.version
611    );
612    let (manifest, manifest_bytes) = fetch_manifest(&client, &package).await?;
613    let distribution = validate_manifest(&manifest, &package, target)?;
614    let cdn_url = cdn_base
615        .map(|base| {
616            crate::cdn::archive_url(base, &package.repository, &manifest.tag, &distribution.url)
617        })
618        .transpose()?
619        .flatten();
620    let archive = crate::cdn::download_with_fallback(
621        &client,
622        cdn_url.as_ref(),
623        &distribution.url,
624        distribution.size,
625        &distribution.sha256,
626        "archive",
627    )
628    .await?;
629    let agent_dir = crate::config::agent_dir().map_err(|e| e.to_string())?;
630    let target = target.to_string();
631    let installed = tokio::task::spawn_blocking(move || {
632        install_archive_at(
633            &package,
634            &manifest,
635            &manifest_bytes,
636            &archive,
637            &target,
638            &agent_dir,
639            force,
640        )
641    })
642    .await
643    .map_err(|e| format!("prebuilt installation failed: {e}"))??;
644    for path in installed {
645        println!("installed {}", path.display());
646    }
647    println!("rpi will validate the plugin entrypoint/SDK API size and load this extension on the next start.");
648    Ok(())
649}
650
651fn install_archive_at(
652    package: &ResolvedPackage,
653    manifest: &Manifest,
654    manifest_bytes: &[u8],
655    archive: &[u8],
656    target: &str,
657    agent_dir: &Path,
658    force: bool,
659) -> Result<Vec<PathBuf>, String> {
660    let distribution = validate_manifest(manifest, package, target)?;
661    if archive.len() as u64 != distribution.size {
662        return Err("archive byte length does not match manifest".into());
663    }
664    check_hash(archive, &distribution.sha256)?;
665    // Everything is verified in memory before the extension store is touched.
666    let contents = extract_contents(archive, &distribution.format, &distribution.library.path)?;
667    check_hash(&contents.library, &distribution.library.sha256)?;
668    validate_library_target(&contents.library, target)?;
669    let provenance = PrebuiltRecord {
670        repository: package.repository.clone(),
671        target: target.into(),
672        manifest_sha256: hash(manifest_bytes),
673        source_commit: manifest.source_commit.clone(),
674        sdk_version: manifest.sdk.version.clone(),
675        plugin_abi: manifest.sdk.plugin_abi,
676        archive_sha256: distribution.sha256.clone(),
677        library_sha256: distribution.library.sha256.clone(),
678        custom_repository: package.custom_repository,
679        assets: contents
680            .assets
681            .iter()
682            .map(|asset| PrebuiltAsset {
683                path: asset.path.clone(),
684                sha256: asset.sha256.clone(),
685            })
686            .collect(),
687    };
688    let temporary = tempfile::tempdir().map_err(|e| e.to_string())?;
689    let artifact = temporary.path().join(&distribution.library.path);
690    std::fs::write(&artifact, &contents.library).map_err(|e| e.to_string())?;
691    let assets: Vec<crate::install::NativeAssetFile> = contents
692        .assets
693        .into_iter()
694        .map(|asset| crate::install::NativeAssetFile {
695            path: asset.path,
696            bytes: asset.bytes,
697        })
698        .collect();
699    let record = crate::install::InstalledNativePackage {
700        name: package.name.clone(),
701        version: package.version.clone(),
702        // Older hosts regard this as a non-registry source and will not rebuild it.
703        source: Some(format!("prebuilt:{}", package.repository)),
704        prebuilt: Some(provenance),
705        artifacts: Vec::new(),
706    };
707    crate::install::install_native_package_with_assets_at(
708        &agent_dir.join("extensions"),
709        &agent_dir.join("native-packages.json"),
710        record,
711        &[artifact],
712        &assets,
713        force,
714    )
715}
716
717fn safe_entry_name(name: &str) -> Result<PathBuf, String> {
718    if name.contains(['\\', ':'])
719        || Path::new(name).components().any(|p| {
720            matches!(
721                p,
722                Component::ParentDir | Component::RootDir | Component::Prefix(_)
723            )
724        })
725    {
726        return Err("unsafe archive entry path".into());
727    }
728    Ok(Path::new(name)
729        .components()
730        .filter(|c| !matches!(c, Component::CurDir))
731        .collect())
732}
733
734fn read_library(reader: impl Read) -> Result<Vec<u8>, String> {
735    let mut bytes = Vec::new();
736    reader
737        .take(MAX_LIBRARY + 1)
738        .read_to_end(&mut bytes)
739        .map_err(|e| e.to_string())?;
740    if bytes.len() as u64 > MAX_LIBRARY {
741        return Err("extracted library exceeds size limit".into());
742    }
743    Ok(bytes)
744}
745
746#[derive(Clone, Copy, PartialEq, Eq)]
747enum EntryKind {
748    Directory,
749    File,
750}
751
752/// Visit every entry of a prebuilt archive in archive order. Unsafe names, links,
753/// more than `MAX_ENTRIES` entries and more than `total` expanded bytes are rejected
754/// here, for every archive. Visitors decide what to read.
755fn walk_archive(
756    archive: &[u8],
757    format: &str,
758    total: u64,
759    mut visit: impl FnMut(&Path, EntryKind, &mut dyn Read) -> Result<(), String>,
760) -> Result<(), String> {
761    let mut expanded = 0u64;
762    match format {
763        "zip" => {
764            let mut zip = zip::ZipArchive::new(Cursor::new(archive)).map_err(|e| e.to_string())?;
765            if zip.len() > MAX_ENTRIES {
766                return Err("too many archive entries".into());
767            }
768            for i in 0..zip.len() {
769                let mut file = zip.by_index(i).map_err(|e| e.to_string())?;
770                let path = safe_entry_name(file.name())?;
771                if file
772                    .unix_mode()
773                    .is_some_and(|mode| mode & 0o170000 == 0o120000)
774                {
775                    return Err("archive symlinks are not allowed".into());
776                }
777                expanded = expanded
778                    .checked_add(file.size())
779                    .ok_or("archive size overflow")?;
780                if expanded > total {
781                    return Err("expanded archive exceeds size limit".into());
782                }
783                let kind = if file.is_dir() {
784                    EntryKind::Directory
785                } else {
786                    EntryKind::File
787                };
788                visit(&path, kind, &mut file)?;
789            }
790        }
791        "tar.gz" => {
792            let decoder = flate2::read::GzDecoder::new(Cursor::new(archive)).take(total + 1);
793            let mut tar = tar::Archive::new(decoder);
794            for (i, entry) in tar.entries().map_err(|e| e.to_string())?.enumerate() {
795                if i >= MAX_ENTRIES {
796                    return Err("too many archive entries".into());
797                }
798                let mut file = entry.map_err(|e| e.to_string())?;
799                let name = file
800                    .path()
801                    .map_err(|e| e.to_string())?
802                    .to_string_lossy()
803                    .into_owned();
804                let path = safe_entry_name(&name)?;
805                let kind = file.header().entry_type();
806                if !kind.is_file() && !kind.is_dir() {
807                    return Err("archive links and special files are not allowed".into());
808                }
809                expanded = expanded
810                    .checked_add(file.size())
811                    .ok_or("archive size overflow")?;
812                if expanded > total {
813                    return Err("expanded archive exceeds size limit".into());
814                }
815                let kind = if kind.is_dir() {
816                    EntryKind::Directory
817                } else {
818                    EntryKind::File
819                };
820                visit(&path, kind, &mut file)?;
821            }
822        }
823        _ => return Err("unsupported prebuilt archive format".into()),
824    }
825    Ok(())
826}
827
828/// Read only the declared library. Legacy callers, such as the CLI's own release
829/// archives, never carry sidecars, so every other entry is ignored.
830pub(crate) fn extract_library(
831    archive: &[u8],
832    format: &str,
833    library: &str,
834) -> Result<Vec<u8>, String> {
835    let mut found = None;
836    walk_archive(archive, format, MAX_ARCHIVE, |path, kind, reader| {
837        if kind == EntryKind::File && path == Path::new(library) {
838            if found.is_some() {
839                return Err("duplicate declared library in archive".into());
840            }
841            found = Some(read_library(reader)?);
842        }
843        Ok(())
844    })?;
845    found.ok_or_else(|| format!("archive does not contain the exact declared library {library}"))
846}
847
848/// Extract a prebuilt package: its library and, when the archive has a
849/// `runtime-assets.json` index, every declared sidecar, all verified.
850pub(crate) fn extract_contents(
851    archive: &[u8],
852    format: &str,
853    library: &str,
854) -> Result<PrebuiltContents, String> {
855    extract_contents_with_limits(archive, format, library, ArchiveLimits::PREBUILT)
856}
857
858/// Two passes over the archive. The first reads the library and the index, so
859/// the index can be validated before any sidecar byte is read. The second
860/// reads each declared sidecar and rejects anything the index does not name.
861/// Without an index the archive is the legacy single-library layout.
862fn extract_contents_with_limits(
863    archive: &[u8],
864    format: &str,
865    library: &str,
866    limits: ArchiveLimits,
867) -> Result<PrebuiltContents, String> {
868    let library_path = Path::new(library);
869    let index_path = Path::new(RUNTIME_ASSETS_FILE);
870    let mut produced = 0u64;
871    let mut library_bytes = None;
872    let mut index_bytes = None;
873    walk_archive(archive, format, limits.total, |path, kind, reader| {
874        if kind == EntryKind::Directory {
875            return Ok(());
876        }
877        if path == library_path {
878            if library_bytes.is_some() {
879                return Err("duplicate declared library in archive".into());
880            }
881            library_bytes = Some(read_budgeted(
882                reader,
883                &mut produced,
884                limits,
885                limits.per_file,
886                "extracted library exceeds size limit",
887            )?);
888        } else if path == index_path {
889            if index_bytes.is_some() {
890                return Err(format!("duplicate {RUNTIME_ASSETS_FILE} in archive"));
891            }
892            index_bytes = Some(read_budgeted(
893                reader,
894                &mut produced,
895                limits,
896                MAX_ASSET_INDEX,
897                "runtime-assets.json exceeds size limit",
898            )?);
899        }
900        Ok(())
901    })?;
902    let library_bytes = library_bytes
903        .ok_or_else(|| format!("archive does not contain the exact declared library {library}"))?;
904    let Some(index_bytes) = index_bytes else {
905        return Ok(PrebuiltContents {
906            library: library_bytes,
907            assets: Vec::new(),
908        });
909    };
910    let declared = declared_assets(&index_bytes, library, limits)?;
911    let mut found: Vec<Option<Vec<u8>>> = vec![None; declared.len()];
912    walk_archive(archive, format, limits.total, |path, kind, reader| {
913        if kind == EntryKind::Directory {
914            if has_node_modules(path) {
915                return Err(format!("archive contains node_modules: {}", path.display()));
916            }
917            return Ok(());
918        }
919        // Already read and checked by the first pass.
920        if path == library_path || path == index_path {
921            return Ok(());
922        }
923        let Some(position) = declared
924            .iter()
925            .position(|asset| Path::new(&asset.path) == path)
926        else {
927            return Err(format!("undeclared file in archive: {}", path.display()));
928        };
929        let asset = &declared[position];
930        if found[position].is_some() {
931            return Err(format!(
932                "duplicate declared asset in archive: {}",
933                asset.path
934            ));
935        }
936        let bytes = read_budgeted(
937            reader,
938            &mut produced,
939            limits,
940            limits.per_file,
941            "extracted asset exceeds size limit",
942        )?;
943        if bytes.len() as u64 != asset.size {
944            return Err(format!(
945                "asset {} size does not match runtime-assets.json",
946                asset.path
947            ));
948        }
949        check_hash(&bytes, &asset.sha256).map_err(|e| format!("asset {}: {e}", asset.path))?;
950        found[position] = Some(bytes);
951        Ok(())
952    })?;
953    let mut assets = Vec::with_capacity(declared.len());
954    for (asset, bytes) in declared.into_iter().zip(found) {
955        let bytes = bytes
956            .ok_or_else(|| format!("archive does not contain declared asset {}", asset.path))?;
957        assets.push(VerifiedAsset {
958            path: asset.path,
959            sha256: asset.sha256,
960            bytes,
961        });
962    }
963    Ok(PrebuiltContents {
964        library: library_bytes,
965        assets,
966    })
967}
968
969/// Parse and validate `runtime-assets.json`. Paths, sizes, digests and collisions
970/// are all checked before any sidecar is read.
971fn declared_assets(
972    bytes: &[u8],
973    library: &str,
974    limits: ArchiveLimits,
975) -> Result<Vec<RuntimeAsset>, String> {
976    let index: RuntimeAssetIndex =
977        serde_json::from_slice(bytes).map_err(|e| format!("invalid {RUNTIME_ASSETS_FILE}: {e}"))?;
978    if index.schema_version != 1 {
979        return Err(format!(
980            "unsupported runtime asset index schema {}",
981            index.schema_version
982        ));
983    }
984    // The library and the index are two of the archive's entries.
985    if index.assets.len() + 2 > MAX_ENTRIES {
986        return Err("too many runtime assets".into());
987    }
988    let reserved = [
989        library.to_ascii_lowercase(),
990        RUNTIME_ASSETS_FILE.to_ascii_lowercase(),
991    ];
992    let mut declared: Vec<RuntimeAsset> = Vec::with_capacity(index.assets.len());
993    for asset in index.assets {
994        validate_asset_path(&asset.path)?;
995        if reserved
996            .iter()
997            .any(|name| asset_paths_conflict(name, &asset.path))
998        {
999            return Err(format!(
1000                "asset path `{}` collides with the library or manifest",
1001                asset.path
1002            ));
1003        }
1004        if declared
1005            .iter()
1006            .any(|other| asset_paths_conflict(&other.path, &asset.path))
1007        {
1008            return Err(format!(
1009                "duplicate or overlapping asset path `{}`",
1010                asset.path
1011            ));
1012        }
1013        if asset.size > limits.per_file {
1014            return Err(format!("runtime asset {} exceeds size limit", asset.path));
1015        }
1016        if !is_sha256_hex(&asset.sha256) {
1017            return Err(format!("invalid SHA-256 for runtime asset {}", asset.path));
1018        }
1019        declared.push(asset);
1020    }
1021    Ok(declared)
1022}
1023
1024/// Read one archive entry under its per-file cap and the archive's remaining
1025/// expansion budget. Budgets count bytes actually produced, so a header that
1026/// understates its size cannot bypass them.
1027fn read_budgeted(
1028    reader: &mut dyn Read,
1029    produced: &mut u64,
1030    limits: ArchiveLimits,
1031    per_file: u64,
1032    file_error: &str,
1033) -> Result<Vec<u8>, String> {
1034    let remaining = limits.total.saturating_sub(*produced);
1035    let limit = per_file.min(remaining);
1036    let mut bytes = Vec::new();
1037    reader
1038        .take(limit.saturating_add(1))
1039        .read_to_end(&mut bytes)
1040        .map_err(|e| e.to_string())?;
1041    if bytes.len() as u64 > limit {
1042        return Err(if limit == remaining {
1043            "expanded archive exceeds size limit".into()
1044        } else {
1045            file_error.into()
1046        });
1047    }
1048    *produced += bytes.len() as u64;
1049    Ok(bytes)
1050}
1051
1052/// Sidecar paths are relative, forward-slash and plain ASCII. Components cannot be
1053/// empty, `.`, `..` or hidden, cannot be a Windows device name or end in a dot,
1054/// cannot be `node_modules`, and cannot carry a native library extension, because
1055/// the loader scans the extension store for libraries.
1056pub(crate) fn validate_asset_path(value: &str) -> Result<(), String> {
1057    let invalid = || format!("invalid asset path `{value}`");
1058    if value.is_empty() || value.len() > MAX_ASSET_PATH {
1059        return Err(invalid());
1060    }
1061    for component in value.split('/') {
1062        // Windows maps these stems to devices even with an extension, and drops trailing dots.
1063        let stem = component
1064            .split('.')
1065            .next()
1066            .unwrap_or_default()
1067            .to_ascii_uppercase();
1068        let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
1069            || (stem.len() == 4
1070                && (stem.starts_with("COM") || stem.starts_with("LPT"))
1071                && stem.as_bytes()[3].is_ascii_digit());
1072        if component.is_empty()
1073            || component.starts_with('.')
1074            || component.ends_with('.')
1075            || device
1076            || !component
1077                .bytes()
1078                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
1079        {
1080            return Err(invalid());
1081        }
1082        if component.eq_ignore_ascii_case("node_modules") {
1083            return Err(format!(
1084                "asset `{value}` vendors node_modules; dependencies must be installed at runtime"
1085            ));
1086        }
1087        if crate::install::is_dynamic_library(Path::new(component)) {
1088            return Err(format!(
1089                "asset `{value}` has a native library extension and cannot be installed as an asset"
1090            ));
1091        }
1092    }
1093    Ok(())
1094}
1095
1096/// Whether two store paths name one file, compared case-insensitively, or nest one under the other.
1097pub(crate) fn asset_paths_conflict(left: &str, right: &str) -> bool {
1098    let (left, right) = (left.to_ascii_lowercase(), right.to_ascii_lowercase());
1099    left == right
1100        || left.starts_with(&format!("{right}/"))
1101        || right.starts_with(&format!("{left}/"))
1102}
1103
1104fn has_node_modules(path: &Path) -> bool {
1105    path.components()
1106        .any(|part| part.as_os_str().eq_ignore_ascii_case("node_modules"))
1107}
1108
1109fn is_sha256_hex(value: &str) -> bool {
1110    value.len() == 64
1111        && value
1112            .bytes()
1113            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1114}
1115
1116/// Verify the native object format/architecture without executing downloaded code.
1117fn validate_library_target(bytes: &[u8], target: &str) -> Result<(), String> {
1118    validate_native_target(bytes, target, true)
1119}
1120
1121pub(crate) fn validate_executable_target(bytes: &[u8], target: &str) -> Result<(), String> {
1122    validate_native_target(bytes, target, false)
1123}
1124
1125fn validate_native_target(bytes: &[u8], target: &str, library: bool) -> Result<(), String> {
1126    let u16_at = |n: usize| {
1127        bytes
1128            .get(n..n.checked_add(2)?)
1129            .map(|b| u16::from_le_bytes([b[0], b[1]]))
1130    };
1131    let u32_at = |n: usize| {
1132        bytes
1133            .get(n..n.checked_add(4)?)
1134            .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
1135    };
1136    let valid = match target {
1137        "x86_64-pc-windows-msvc" => {
1138            let offset = u32_at(0x3c).unwrap_or(u32::MAX) as usize;
1139            bytes.starts_with(b"MZ")
1140                && bytes.get(offset..offset.saturating_add(4)) == Some(b"PE\0\0".as_slice())
1141                && u16_at(offset.saturating_add(4)) == Some(0x8664)
1142                && u16_at(offset.saturating_add(22)).is_some_and(|flags| {
1143                    if library {
1144                        flags & 0x2000 != 0
1145                    } else {
1146                        flags & 0x2000 == 0 && flags & 2 != 0
1147                    }
1148                })
1149        }
1150        "x86_64-unknown-linux-gnu" | "x86_64-unknown-linux-musl" | "aarch64-unknown-linux-gnu" => {
1151            bytes.starts_with(b"\x7fELF\x02\x01")
1152                && u16_at(16).is_some_and(|kind| kind == 3 || (!library && kind == 2))
1153                && u16_at(18)
1154                    == Some(if target.starts_with("aarch64") {
1155                        183
1156                    } else {
1157                        62
1158                    })
1159        }
1160        "x86_64-apple-darwin" | "aarch64-apple-darwin" => {
1161            u32_at(0) == Some(0xfeedfacf)
1162                && u32_at(12) == Some(if library { 6 } else { 2 })
1163                && u32_at(4)
1164                    == Some(if target.starts_with("aarch64") {
1165                        0x0100000c
1166                    } else {
1167                        0x01000007
1168                    })
1169        }
1170        _ => false,
1171    };
1172    if !valid {
1173        return Err(format!(
1174            "declared artifact is not a compatible native {} for {target}",
1175            if library {
1176                "shared library"
1177            } else {
1178                "executable"
1179            }
1180        ));
1181    }
1182    Ok(())
1183}
1184
1185#[cfg(test)]
1186mod tests {
1187    use super::*;
1188    use std::io::Write;
1189
1190    const TARGETS: [&str; 6] = [
1191        "x86_64-pc-windows-msvc",
1192        "x86_64-unknown-linux-gnu",
1193        "x86_64-unknown-linux-musl",
1194        "aarch64-unknown-linux-gnu",
1195        "x86_64-apple-darwin",
1196        "aarch64-apple-darwin",
1197    ];
1198
1199    fn object(target: &str) -> Vec<u8> {
1200        let mut bytes = vec![0u8; 128];
1201        if target.contains("windows") {
1202            bytes[..2].copy_from_slice(b"MZ");
1203            bytes[0x3c..0x40].copy_from_slice(&64u32.to_le_bytes());
1204            bytes[64..68].copy_from_slice(b"PE\0\0");
1205            bytes[68..70].copy_from_slice(&0x8664u16.to_le_bytes());
1206            bytes[86..88].copy_from_slice(&0x2000u16.to_le_bytes());
1207        } else if target.contains("linux") {
1208            bytes[..6].copy_from_slice(b"\x7fELF\x02\x01");
1209            bytes[16..18].copy_from_slice(&3u16.to_le_bytes());
1210            bytes[18..20].copy_from_slice(
1211                &(if target.starts_with("aarch64") {
1212                    183u16
1213                } else {
1214                    62u16
1215                })
1216                .to_le_bytes(),
1217            );
1218        } else {
1219            bytes[..4].copy_from_slice(&0xfeedfacfu32.to_le_bytes());
1220            bytes[4..8].copy_from_slice(
1221                &(if target.starts_with("aarch64") {
1222                    0x0100000cu32
1223                } else {
1224                    0x01000007u32
1225                })
1226                .to_le_bytes(),
1227            );
1228            bytes[12..16].copy_from_slice(&6u32.to_le_bytes());
1229        }
1230        bytes
1231    }
1232
1233    fn zip(files: &[(&str, &[u8])]) -> Vec<u8> {
1234        let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
1235        for (name, bytes) in files {
1236            zip.start_file(*name, zip::write::SimpleFileOptions::default())
1237                .unwrap();
1238            zip.write_all(bytes).unwrap();
1239        }
1240        zip.finish().unwrap().into_inner()
1241    }
1242
1243    fn tgz(files: &[(&str, &[u8])]) -> Vec<u8> {
1244        let mut tar = tar::Builder::new(flate2::write::GzEncoder::new(
1245            Vec::new(),
1246            flate2::Compression::default(),
1247        ));
1248        for (name, bytes) in files {
1249            let mut header = tar::Header::new_gnu();
1250            header.set_size(bytes.len() as u64);
1251            header.set_mode(0o644);
1252            header.set_cksum();
1253            tar.append_data(&mut header, name, *bytes).unwrap();
1254        }
1255        tar.into_inner().unwrap().finish().unwrap()
1256    }
1257
1258    fn fixture(target: &str) -> (ResolvedPackage, serde_json::Value, Vec<u8>) {
1259        fixture_with_entries(target, &[])
1260    }
1261
1262    /// The fixture release for `target`, with `extra` files archived beside the library.
1263    fn fixture_with_entries(
1264        target: &str,
1265        extra: &[(&str, &[u8])],
1266    ) -> (ResolvedPackage, serde_json::Value, Vec<u8>) {
1267        let package = ResolvedPackage {
1268            name: "independent-plugin".into(),
1269            version: "1.2.3".into(),
1270            repository: "https://github.com/publisher/monorepo".into(),
1271            custom_repository: false,
1272        };
1273        // The library target deliberately differs from the crate name.
1274        let (format, library, file) = if target.contains("windows") {
1275            ("zip", "custom_runtime.dll", "package.zip")
1276        } else if target.contains("apple") {
1277            ("tar.gz", "libcustom_runtime.dylib", "package.tar.gz")
1278        } else {
1279            ("tar.gz", "libcustom_runtime.so", "package.tar.gz")
1280        };
1281        let bytes = object(target);
1282        let mut entries = vec![(library, bytes.as_slice())];
1283        entries.extend_from_slice(extra);
1284        let archive = if format == "zip" {
1285            zip(&entries)
1286        } else {
1287            tgz(&entries)
1288        };
1289        let manifest = serde_json::json!({
1290            "schema_version":1, "name":package.name, "version":package.version,
1291            "repository":package.repository, "tag":"independent-plugin-v1.2.3", "source_commit":"a".repeat(40),
1292            "sdk":{"version":"0.3.23", "plugin_abi":rpi_plugin_sdk::RPI_PLUGIN_ABI_VERSION_UNIFIED},
1293            "targets":{target:{"url":release_url(&package.repository,&package.name,&package.version,file).unwrap(),
1294                "format":format,"size":archive.len(),"sha256":hash(&archive),"library":{"path":library,"sha256":hash(&bytes)}}}
1295        });
1296        (package, manifest, archive)
1297    }
1298
1299    #[test]
1300    fn declared_runtime_assets_install_beside_the_library() {
1301        let target = TARGETS[0];
1302        let worker = b"export const ready = true;\n";
1303        let index = serde_json::to_vec(&serde_json::json!({
1304            "schema_version": 1,
1305            "assets": [{"path": "runtime/main.mjs", "size": worker.len(), "sha256": hash(worker)}]
1306        }))
1307        .unwrap();
1308        let (package, value, archive) = fixture_with_entries(
1309            target,
1310            &[
1311                ("runtime-assets.json", &index[..]),
1312                ("runtime/main.mjs", &worker[..]),
1313            ],
1314        );
1315        let manifest: Manifest = serde_json::from_value(value.clone()).unwrap();
1316        let agent = tempfile::tempdir().unwrap();
1317        let paths = install_archive_at(
1318            &package,
1319            &manifest,
1320            &serde_json::to_vec(&value).unwrap(),
1321            &archive,
1322            target,
1323            agent.path(),
1324            false,
1325        )
1326        .unwrap();
1327        let extensions = agent.path().join("extensions");
1328        assert_eq!(
1329            std::fs::read(extensions.join("runtime/main.mjs")).unwrap(),
1330            worker
1331        );
1332        assert!(paths.contains(&extensions.join("runtime/main.mjs")));
1333        assert!(!extensions.join("runtime-assets.json").exists());
1334    }
1335
1336    #[test]
1337    fn runtime_asset_index_rejects_unsafe_or_inconsistent_declarations() {
1338        let library = object(TARGETS[0]);
1339        let worker: &[u8] = b"export const ready = true;\n";
1340        let entry = |path: &str, bytes: &[u8]| serde_json::json!({"path": path, "size": bytes.len(), "sha256": hash(bytes)});
1341        let index = |assets: Vec<serde_json::Value>| {
1342            serde_json::to_vec(&serde_json::json!({"schema_version": 1, "assets": assets})).unwrap()
1343        };
1344        let many: Vec<serde_json::Value> = (0..127)
1345            .map(|i| entry(&format!("assets/f{i}.mjs"), worker))
1346            .collect();
1347        let cases: Vec<(&str, Vec<u8>, Vec<(&str, Vec<u8>)>, &str)> = vec![
1348            (
1349                "absolute path",
1350                index(vec![entry("/etc/main.mjs", worker)]),
1351                vec![],
1352                "invalid asset path",
1353            ),
1354            (
1355                "parent traversal",
1356                index(vec![entry("../main.mjs", worker)]),
1357                vec![],
1358                "invalid asset path",
1359            ),
1360            (
1361                "dot component",
1362                index(vec![entry("./main.mjs", worker)]),
1363                vec![],
1364                "invalid asset path",
1365            ),
1366            (
1367                "backslash",
1368                index(vec![entry("runtime\\main.mjs", worker)]),
1369                vec![],
1370                "invalid asset path",
1371            ),
1372            (
1373                "drive prefix",
1374                index(vec![entry("C:main.mjs", worker)]),
1375                vec![],
1376                "invalid asset path",
1377            ),
1378            (
1379                "hidden component",
1380                index(vec![entry(".env", worker)]),
1381                vec![],
1382                "invalid asset path",
1383            ),
1384            (
1385                "trailing slash",
1386                index(vec![entry("runtime/", worker)]),
1387                vec![],
1388                "invalid asset path",
1389            ),
1390            (
1391                "overlong path",
1392                index(vec![entry(&format!("{}.mjs", "a".repeat(510)), worker)]),
1393                vec![],
1394                "invalid asset path",
1395            ),
1396            (
1397                "node_modules",
1398                index(vec![entry("runtime/node_modules/dep.mjs", worker)]),
1399                vec![],
1400                "vendors node_modules",
1401            ),
1402            (
1403                "native library",
1404                index(vec![entry("native/helper.so", worker)]),
1405                vec![],
1406                "native library extension",
1407            ),
1408            (
1409                "index name",
1410                index(vec![entry("runtime-assets.json", worker)]),
1411                vec![],
1412                "collides with the library or manifest",
1413            ),
1414            (
1415                "case-folded duplicate",
1416                index(vec![
1417                    entry("runtime/main.mjs", worker),
1418                    entry("Runtime/Main.mjs", worker),
1419                ]),
1420                vec![],
1421                "duplicate or overlapping",
1422            ),
1423            (
1424                "nested path",
1425                index(vec![
1426                    entry("runtime", worker),
1427                    entry("runtime/main.mjs", worker),
1428                ]),
1429                vec![],
1430                "duplicate or overlapping",
1431            ),
1432            (
1433                "malformed digest",
1434                index(vec![
1435                    serde_json::json!({"path": "main.mjs", "size": worker.len(), "sha256": "ABC"}),
1436                ]),
1437                vec![],
1438                "invalid SHA-256",
1439            ),
1440            (
1441                "windows device name",
1442                index(vec![entry("runtime/CON.mjs", worker)]),
1443                vec![],
1444                "invalid asset path",
1445            ),
1446            (
1447                "windows bare device name",
1448                index(vec![entry("runtime/nul", worker)]),
1449                vec![],
1450                "invalid asset path",
1451            ),
1452            (
1453                "windows trailing dot",
1454                index(vec![entry("runtime/main.mjs.", worker)]),
1455                vec![],
1456                "invalid asset path",
1457            ),
1458            (
1459                "empty component",
1460                index(vec![entry("runtime//main.mjs", worker)]),
1461                vec![],
1462                "invalid asset path",
1463            ),
1464            (
1465                "unc prefix",
1466                index(vec![entry("\\\\server\\share\\main.mjs", worker)]),
1467                vec![],
1468                "invalid asset path",
1469            ),
1470            (
1471                "missing digest",
1472                index(vec![
1473                    serde_json::json!({"path": "main.mjs", "size": worker.len()}),
1474                ]),
1475                vec![("main.mjs", worker.to_vec())],
1476                "invalid runtime-assets.json",
1477            ),
1478            (
1479                "uppercase digest",
1480                index(vec![
1481                    serde_json::json!({"path": "main.mjs", "size": worker.len(), "sha256": hash(worker).to_uppercase()}),
1482                ]),
1483                vec![("main.mjs", worker.to_vec())],
1484                "invalid SHA-256",
1485            ),
1486            (
1487                "traversing archive entry",
1488                index(vec![]),
1489                vec![("../escape.mjs", worker.to_vec())],
1490                "unsafe archive entry path",
1491            ),
1492            (
1493                "absolute archive entry",
1494                index(vec![]),
1495                vec![("/escape.mjs", worker.to_vec())],
1496                "unsafe archive entry path",
1497            ),
1498            (
1499                "backslash archive entry",
1500                index(vec![entry("runtime/main.mjs", worker)]),
1501                vec![("runtime\\main.mjs", worker.to_vec())],
1502                "unsafe archive entry path",
1503            ),
1504            (
1505                "case-folded archive entry",
1506                index(vec![entry("runtime/main.mjs", worker)]),
1507                vec![("Runtime/Main.mjs", worker.to_vec())],
1508                "undeclared file in archive",
1509            ),
1510            (
1511                "unknown schema",
1512                serde_json::to_vec(&serde_json::json!({"schema_version": 2, "assets": []}))
1513                    .unwrap(),
1514                vec![],
1515                "unsupported runtime asset index schema",
1516            ),
1517            (
1518                "malformed index",
1519                b"{".to_vec(),
1520                vec![],
1521                "invalid runtime-assets.json",
1522            ),
1523            (
1524                "too many assets",
1525                index(many),
1526                vec![],
1527                "too many runtime assets",
1528            ),
1529            (
1530                "undeclared file",
1531                index(vec![]),
1532                vec![("runtime/main.mjs", worker.to_vec())],
1533                "undeclared file in archive",
1534            ),
1535            (
1536                "missing declared file",
1537                index(vec![entry("runtime/main.mjs", worker)]),
1538                vec![],
1539                "does not contain declared asset",
1540            ),
1541            (
1542                "size mismatch",
1543                index(vec![
1544                    serde_json::json!({"path": "main.mjs", "size": worker.len() + 1, "sha256": hash(worker)}),
1545                ]),
1546                vec![("main.mjs", worker.to_vec())],
1547                "size does not match",
1548            ),
1549            (
1550                "digest mismatch",
1551                index(vec![
1552                    serde_json::json!({"path": "main.mjs", "size": worker.len(), "sha256": hash(b"other")}),
1553                ]),
1554                vec![("main.mjs", worker.to_vec())],
1555                "SHA-256 mismatch",
1556            ),
1557        ];
1558        for (label, index, extra, expected) in cases {
1559            let mut entries: Vec<(&str, &[u8])> = vec![
1560                ("custom_runtime.dll", library.as_slice()),
1561                ("runtime-assets.json", index.as_slice()),
1562            ];
1563            entries.extend(extra.iter().map(|(name, bytes)| (*name, bytes.as_slice())));
1564            let Err(error) = extract_contents(&zip(&entries), "zip", "custom_runtime.dll") else {
1565                panic!("{label} was accepted");
1566            };
1567            assert!(error.contains(expected), "{label}: {error}");
1568        }
1569    }
1570
1571    #[test]
1572    fn runtime_asset_limits_bound_each_file_and_the_whole_archive() {
1573        let library = object(TARGETS[0]);
1574        let worker = vec![b'x'; 200];
1575        let index = |size: u64| {
1576            serde_json::to_vec(&serde_json::json!({
1577                "schema_version": 1,
1578                "assets": [{"path": "runtime/main.mjs", "size": size, "sha256": hash(&worker)}]
1579            }))
1580            .unwrap()
1581        };
1582        let declared = index(worker.len() as u64);
1583        let archive = |index: &[u8], worker: &[u8]| {
1584            zip(&[
1585                ("custom_runtime.dll", library.as_slice()),
1586                ("runtime-assets.json", index),
1587                ("runtime/main.mjs", worker),
1588            ])
1589        };
1590        let limits = |per_file, total| ArchiveLimits { per_file, total };
1591
1592        // A sidecar larger than the per-file cap is refused by its declaration.
1593        let error = extract_contents_with_limits(
1594            &archive(&declared, &worker),
1595            "zip",
1596            "custom_runtime.dll",
1597            limits(128, u64::MAX),
1598        )
1599        .err()
1600        .unwrap();
1601        assert!(
1602            error.contains("runtime asset runtime/main.mjs exceeds size limit"),
1603            "{error}"
1604        );
1605
1606        // A declaration that understates the bytes is stopped by the read cap.
1607        let understated = index(4);
1608        let error = extract_contents_with_limits(
1609            &archive(&understated, &worker),
1610            "zip",
1611            "custom_runtime.dll",
1612            limits(128, u64::MAX),
1613        )
1614        .err()
1615        .unwrap();
1616        assert!(
1617            error.contains("extracted asset exceeds size limit"),
1618            "{error}"
1619        );
1620
1621        // The expanded total counts every entry, including the library and index.
1622        let exact = library.len() as u64 + declared.len() as u64 + worker.len() as u64;
1623        let contents = extract_contents_with_limits(
1624            &archive(&declared, &worker),
1625            "zip",
1626            "custom_runtime.dll",
1627            limits(u64::MAX, exact),
1628        )
1629        .unwrap();
1630        assert_eq!(contents.assets[0].bytes, worker);
1631        let error = extract_contents_with_limits(
1632            &archive(&declared, &worker),
1633            "zip",
1634            "custom_runtime.dll",
1635            limits(u64::MAX, exact - 1),
1636        )
1637        .err()
1638        .unwrap();
1639        assert!(
1640            error.contains("expanded archive exceeds size limit"),
1641            "{error}"
1642        );
1643    }
1644
1645    #[test]
1646    fn archive_walk_bounds_expanded_size_for_every_archive() {
1647        // Undeclared entries are never read, so the header-declared total is the
1648        // only bound on a legacy archive's expansion.
1649        let archive = zip(&[("a.bin", &[0u8; 12][..]), ("b.bin", &[0u8; 12][..])]);
1650        let error = walk_archive(&archive, "zip", 20, |_, _, _| Ok(())).unwrap_err();
1651        assert!(
1652            error.contains("expanded archive exceeds size limit"),
1653            "{error}"
1654        );
1655        walk_archive(&archive, "zip", 24, |_, _, _| Ok(())).unwrap();
1656
1657        // The tar.gz stream is capped as well, so its framing counts toward the total.
1658        let tar = tgz(&[("a.bin", &[0u8; 12][..]), ("b.bin", &[0u8; 12][..])]);
1659        assert!(walk_archive(&tar, "tar.gz", 20, |_, _, _| Ok(())).is_err());
1660        walk_archive(&tar, "tar.gz", 64 * 1024, |_, _, _| Ok(())).unwrap();
1661    }
1662
1663    #[test]
1664    fn runtime_asset_index_size_is_capped() {
1665        let mut index = br#"{"schema_version":1,"assets":[]}"#.to_vec();
1666        index.resize(MAX_ASSET_INDEX as usize + 1, b' ');
1667        let library = object(TARGETS[0]);
1668        let archive = zip(&[
1669            ("custom_runtime.dll", library.as_slice()),
1670            ("runtime-assets.json", index.as_slice()),
1671        ]);
1672        let Err(error) = extract_contents(&archive, "zip", "custom_runtime.dll") else {
1673            panic!("an oversized index was accepted");
1674        };
1675        assert!(
1676            error.contains("runtime-assets.json exceeds size limit"),
1677            "{error}"
1678        );
1679    }
1680
1681    #[test]
1682    fn runtime_asset_entry_count_is_exact() {
1683        let library = object(TARGETS[0]);
1684        let worker: &[u8] = b"export const ready = true;\n";
1685        let names: Vec<String> = (0..126).map(|i| format!("assets/f{i}.mjs")).collect();
1686        let assets: Vec<serde_json::Value> = names
1687            .iter()
1688            .map(|name| serde_json::json!({"path": name, "size": worker.len(), "sha256": hash(worker)}))
1689            .collect();
1690        let index = serde_json::to_vec(&serde_json::json!({"schema_version": 1, "assets": assets}))
1691            .unwrap();
1692        let mut entries: Vec<(&str, &[u8])> = vec![
1693            ("custom_runtime.dll", library.as_slice()),
1694            ("runtime-assets.json", index.as_slice()),
1695        ];
1696        entries.extend(names.iter().map(|name| (name.as_str(), worker)));
1697        // 126 sidecars plus the library and the index make the 128-entry ceiling.
1698        let contents = extract_contents(&zip(&entries), "zip", "custom_runtime.dll").unwrap();
1699        assert_eq!(contents.assets.len(), 126);
1700
1701        // A legacy archive is bounded by the same entry ceiling.
1702        let legacy: Vec<String> = (0..129).map(|i| format!("docs/{i}.md")).collect();
1703        let legacy: Vec<(&str, &[u8])> = legacy
1704            .iter()
1705            .map(|name| (name.as_str(), &b"x"[..]))
1706            .collect();
1707        let error = extract_library(&zip(&legacy), "zip", "custom_runtime.dll").unwrap_err();
1708        assert!(error.contains("too many archive entries"), "{error}");
1709    }
1710
1711    /// Rewrite the uncompressed size that the local and central headers of `name`
1712    /// claim. The stored bytes do not change, so the entry still holds its real length.
1713    fn understate_entry(archive: &mut [u8], name: &str, claimed: u32) {
1714        let name = name.as_bytes();
1715        let local: Vec<usize> = (0..archive.len())
1716            .filter(|&at| {
1717                archive[at..].starts_with(b"PK\x03\x04")
1718                    && archive
1719                        .get(at + 30..)
1720                        .is_some_and(|rest| rest.starts_with(name))
1721            })
1722            .collect();
1723        let central: Vec<usize> = (0..archive.len())
1724            .filter(|&at| {
1725                archive[at..].starts_with(b"PK\x01\x02")
1726                    && archive
1727                        .get(at + 46..)
1728                        .is_some_and(|rest| rest.starts_with(name))
1729            })
1730            .collect();
1731        assert!(!local.is_empty() && !central.is_empty());
1732        for at in local {
1733            archive[at + 22..at + 26].copy_from_slice(&claimed.to_le_bytes());
1734        }
1735        for at in central {
1736            archive[at + 24..at + 28].copy_from_slice(&claimed.to_le_bytes());
1737        }
1738    }
1739
1740    #[test]
1741    fn runtime_asset_budget_holds_when_zip_headers_understate_sizes() {
1742        let library = object(TARGETS[0]);
1743        let worker = vec![b'x'; 200];
1744        let index = serde_json::to_vec(&serde_json::json!({
1745            "schema_version": 1,
1746            "assets": [{"path": "runtime/main.mjs", "size": worker.len(), "sha256": hash(&worker)}]
1747        }))
1748        .unwrap();
1749        let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
1750        let stored = zip::write::SimpleFileOptions::default()
1751            .compression_method(zip::CompressionMethod::Stored);
1752        for (name, bytes) in [
1753            ("custom_runtime.dll", library.as_slice()),
1754            ("runtime-assets.json", index.as_slice()),
1755            ("runtime/main.mjs", worker.as_slice()),
1756        ] {
1757            writer.start_file(name, stored).unwrap();
1758            writer.write_all(bytes).unwrap();
1759        }
1760        let mut archive = writer.finish().unwrap().into_inner();
1761        // The headers now claim one byte for the sidecar that really holds 200.
1762        understate_entry(&mut archive, "runtime/main.mjs", 1);
1763
1764        // The walker sees a small entry, so only the read budget can stop the bytes.
1765        let exact = (library.len() + index.len() + worker.len()) as u64;
1766        let limits = ArchiveLimits {
1767            per_file: u64::MAX,
1768            total: exact - 1,
1769        };
1770        let error = extract_contents_with_limits(&archive, "zip", "custom_runtime.dll", limits)
1771            .err()
1772            .unwrap();
1773        assert!(
1774            error.contains("expanded archive exceeds size limit"),
1775            "{error}"
1776        );
1777
1778        let limits = ArchiveLimits {
1779            per_file: u64::MAX,
1780            total: exact,
1781        };
1782        let contents =
1783            extract_contents_with_limits(&archive, "zip", "custom_runtime.dll", limits).unwrap();
1784        assert_eq!(contents.assets[0].bytes, worker);
1785    }
1786
1787    #[test]
1788    fn runtime_asset_archives_reject_symlinked_files_and_node_modules_directories() {
1789        let library = object(TARGETS[0]);
1790        let worker = b"export {};\n";
1791        let index = serde_json::to_vec(&serde_json::json!({
1792            "schema_version": 1,
1793            "assets": [{"path": "runtime/main.mjs", "size": worker.len(), "sha256": hash(worker)}]
1794        }))
1795        .unwrap();
1796        let mut linked = zip::ZipWriter::new(Cursor::new(Vec::new()));
1797        linked
1798            .start_file(
1799                "custom_runtime.dll",
1800                zip::write::SimpleFileOptions::default(),
1801            )
1802            .unwrap();
1803        linked.write_all(&library).unwrap();
1804        linked
1805            .start_file(
1806                "runtime-assets.json",
1807                zip::write::SimpleFileOptions::default(),
1808            )
1809            .unwrap();
1810        linked.write_all(&index).unwrap();
1811        linked
1812            .add_symlink(
1813                "runtime/main.mjs",
1814                "/etc/passwd",
1815                zip::write::SimpleFileOptions::default(),
1816            )
1817            .unwrap();
1818        let archive = linked.finish().unwrap().into_inner();
1819        let Err(error) = extract_contents(&archive, "zip", "custom_runtime.dll") else {
1820            panic!("a symlinked sidecar was accepted");
1821        };
1822        assert!(error.contains("symlinks are not allowed"), "{error}");
1823
1824        let empty =
1825            serde_json::to_vec(&serde_json::json!({"schema_version": 1, "assets": []})).unwrap();
1826        let mut vendored = zip::ZipWriter::new(Cursor::new(Vec::new()));
1827        vendored
1828            .start_file(
1829                "custom_runtime.dll",
1830                zip::write::SimpleFileOptions::default(),
1831            )
1832            .unwrap();
1833        vendored.write_all(&library).unwrap();
1834        vendored
1835            .start_file(
1836                "runtime-assets.json",
1837                zip::write::SimpleFileOptions::default(),
1838            )
1839            .unwrap();
1840        vendored.write_all(&empty).unwrap();
1841        vendored
1842            .add_directory("node_modules/", zip::write::SimpleFileOptions::default())
1843            .unwrap();
1844        let archive = vendored.finish().unwrap().into_inner();
1845        let Err(error) = extract_contents(&archive, "zip", "custom_runtime.dll") else {
1846            panic!("a node_modules directory was accepted");
1847        };
1848        assert!(error.contains("node_modules"), "{error}");
1849    }
1850
1851    #[test]
1852    fn runtime_asset_digest_mismatch_keeps_the_installed_sidecars() {
1853        let target = TARGETS[1];
1854        let worker = b"export {};\n";
1855        let index = serde_json::to_vec(&serde_json::json!({
1856            "schema_version": 1,
1857            "assets": [{"path": "runtime/main.mjs", "size": worker.len(), "sha256": hash(worker)}]
1858        }))
1859        .unwrap();
1860        let (package, value, archive) = fixture_with_entries(
1861            target,
1862            &[
1863                ("runtime-assets.json", &index[..]),
1864                ("runtime/main.mjs", &worker[..]),
1865            ],
1866        );
1867        let manifest: Manifest = serde_json::from_value(value.clone()).unwrap();
1868        let agent = tempfile::tempdir().unwrap();
1869        install_archive_at(
1870            &package,
1871            &manifest,
1872            &serde_json::to_vec(&value).unwrap(),
1873            &archive,
1874            target,
1875            agent.path(),
1876            false,
1877        )
1878        .unwrap();
1879        let metadata = std::fs::read(agent.path().join("native-packages.json")).unwrap();
1880
1881        // The manifest and archive hashes are consistent. The sidecar keeps its size and changes its bytes.
1882        let tampered: &[u8] = b"export [];\n";
1883        assert_eq!(tampered.len(), worker.len());
1884        let (package, value, archive) = fixture_with_entries(
1885            target,
1886            &[
1887                ("runtime-assets.json", &index[..]),
1888                ("runtime/main.mjs", tampered),
1889            ],
1890        );
1891        let manifest: Manifest = serde_json::from_value(value.clone()).unwrap();
1892        let error = install_archive_at(
1893            &package,
1894            &manifest,
1895            &serde_json::to_vec(&value).unwrap(),
1896            &archive,
1897            target,
1898            agent.path(),
1899            true,
1900        )
1901        .unwrap_err();
1902        assert!(
1903            error.contains("asset runtime/main.mjs: SHA-256 mismatch"),
1904            "{error}"
1905        );
1906        let extensions = agent.path().join("extensions");
1907        assert_eq!(
1908            std::fs::read(extensions.join("runtime/main.mjs")).unwrap(),
1909            worker
1910        );
1911        assert_eq!(
1912            std::fs::read(agent.path().join("native-packages.json")).unwrap(),
1913            metadata
1914        );
1915    }
1916
1917    #[test]
1918    fn runtime_asset_tar_archives_reject_duplicate_index_and_declared_entries() {
1919        let library = object(TARGETS[1]);
1920        let worker = b"export {};\n";
1921        let index = serde_json::to_vec(&serde_json::json!({
1922            "schema_version": 1,
1923            "assets": [{"path": "runtime/main.mjs", "size": worker.len(), "sha256": hash(worker)}]
1924        }))
1925        .unwrap();
1926        let archive = tgz(&[
1927            ("libcustom_runtime.so", &library),
1928            ("runtime-assets.json", &index),
1929            ("runtime/main.mjs", worker),
1930            ("runtime/main.mjs", worker),
1931        ]);
1932        let Err(error) = extract_contents(&archive, "tar.gz", "libcustom_runtime.so") else {
1933            panic!("a duplicated sidecar was accepted");
1934        };
1935        assert!(error.contains("duplicate declared asset"), "{error}");
1936
1937        let archive = tgz(&[
1938            ("libcustom_runtime.so", &library),
1939            ("runtime-assets.json", &index),
1940            ("runtime-assets.json", &index),
1941        ]);
1942        let Err(error) = extract_contents(&archive, "tar.gz", "libcustom_runtime.so") else {
1943            panic!("a duplicated index was accepted");
1944        };
1945        assert!(error.contains("duplicate runtime-assets.json"), "{error}");
1946    }
1947
1948    #[test]
1949    fn legacy_archives_install_only_the_library_and_keep_their_records_unchanged() {
1950        for target in TARGETS {
1951            let (package, value, archive) =
1952                fixture_with_entries(target, &[("README.md", b"not a sidecar")]);
1953            let manifest: Manifest = serde_json::from_value(value.clone()).unwrap();
1954            let agent = tempfile::tempdir().unwrap();
1955            let paths = install_archive_at(
1956                &package,
1957                &manifest,
1958                &serde_json::to_vec(&value).unwrap(),
1959                &archive,
1960                target,
1961                agent.path(),
1962                false,
1963            )
1964            .unwrap();
1965            assert_eq!(paths.len(), 1);
1966            assert!(!agent.path().join("extensions/README.md").exists());
1967            let records: serde_json::Value = serde_json::from_slice(
1968                &std::fs::read(agent.path().join("native-packages.json")).unwrap(),
1969            )
1970            .unwrap();
1971            assert!(records[0]["prebuilt"].get("assets").is_none());
1972        }
1973    }
1974
1975    #[test]
1976    fn all_six_distributions_install_and_record_exact_provenance() {
1977        for target in TARGETS {
1978            let (package, value, archive) = fixture(target);
1979            let manifest: Manifest = serde_json::from_value(value.clone()).unwrap();
1980            let raw = serde_json::to_vec(&value).unwrap();
1981            let agent = tempfile::tempdir().unwrap();
1982            let paths = install_archive_at(
1983                &package,
1984                &manifest,
1985                &raw,
1986                &archive,
1987                target,
1988                agent.path(),
1989                false,
1990            )
1991            .unwrap();
1992            assert_eq!(paths.len(), 1);
1993            assert_eq!(std::fs::read(&paths[0]).unwrap(), object(target));
1994            let records: Vec<crate::install::InstalledNativePackage> = serde_json::from_slice(
1995                &std::fs::read(agent.path().join("native-packages.json")).unwrap(),
1996            )
1997            .unwrap();
1998            assert_eq!(records[0].name, "independent-plugin");
1999            assert_eq!(records[0].version, "1.2.3");
2000            assert_eq!(
2001                records[0].artifacts,
2002                [manifest.targets[target].library.path.clone()]
2003            );
2004            let origin = records[0].prebuilt.as_ref().unwrap();
2005            assert_eq!(origin.target, target);
2006            assert_eq!(origin.manifest_sha256, hash(&raw));
2007            assert_eq!(origin.repository, package.repository);
2008            assert_eq!(origin.archive_sha256, hash(&archive));
2009            assert_eq!(origin.library_sha256, hash(&object(target)));
2010        }
2011    }
2012
2013    #[test]
2014    fn prebuilt_packages_upgrade_without_force_and_same_version_is_unchanged() {
2015        for target in TARGETS {
2016            let (mut package, mut value, archive) = fixture(target);
2017            let manifest: Manifest = serde_json::from_value(value.clone()).unwrap();
2018            let agent = tempfile::tempdir().unwrap();
2019            install_archive_at(
2020                &package,
2021                &manifest,
2022                &serde_json::to_vec(&value).unwrap(),
2023                &archive,
2024                target,
2025                agent.path(),
2026                false,
2027            )
2028            .unwrap();
2029            package.version = "1.2.4".into();
2030            value["version"] = serde_json::json!(package.version);
2031            value["tag"] = serde_json::json!("independent-plugin-v1.2.4");
2032            let distribution = value.pointer_mut(&format!("/targets/{target}")).unwrap();
2033            let format = distribution["format"].as_str().unwrap();
2034            let file = if format == "zip" {
2035                "package.zip"
2036            } else {
2037                "package.tar.gz"
2038            };
2039            distribution["url"] = serde_json::json!(release_url(
2040                &package.repository,
2041                &package.name,
2042                &package.version,
2043                file,
2044            )
2045            .unwrap());
2046            let manifest = serde_json::from_value(value.clone()).unwrap();
2047            let raw = serde_json::to_vec(&value).unwrap();
2048            assert_eq!(
2049                install_archive_at(
2050                    &package,
2051                    &manifest,
2052                    &raw,
2053                    &archive,
2054                    target,
2055                    agent.path(),
2056                    false,
2057                )
2058                .unwrap()
2059                .len(),
2060                1
2061            );
2062            let metadata = agent.path().join("native-packages.json");
2063            let registry = std::fs::read(&metadata).unwrap();
2064            let records: Vec<crate::install::InstalledNativePackage> =
2065                serde_json::from_slice(&registry).unwrap();
2066            assert_eq!(records[0].version, "1.2.4");
2067            assert!(install_archive_at(
2068                &package,
2069                &manifest,
2070                &raw,
2071                &archive,
2072                target,
2073                agent.path(),
2074                false,
2075            )
2076            .unwrap()
2077            .is_empty());
2078            assert_eq!(std::fs::read(&metadata).unwrap(), registry);
2079        }
2080    }
2081
2082    #[test]
2083    fn bad_release_identity_hash_abi_and_target_leave_installed_version_intact() {
2084        let target = TARGETS[0];
2085        let (package, good, archive) = fixture(target);
2086        let manifest: Manifest = serde_json::from_value(good.clone()).unwrap();
2087        let agent = tempfile::tempdir().unwrap();
2088        let path = install_archive_at(
2089            &package,
2090            &manifest,
2091            &serde_json::to_vec(&good).unwrap(),
2092            &archive,
2093            target,
2094            agent.path(),
2095            false,
2096        )
2097        .unwrap()
2098        .remove(0);
2099        let original_record = std::fs::read(agent.path().join("native-packages.json")).unwrap();
2100        let mutations = [
2101            ("/schema_version",serde_json::json!(99)), ("/name",serde_json::json!("other-plugin")),
2102            ("/version",serde_json::json!("9.9.9")), ("/tag",serde_json::json!("v1.2.3")),
2103            ("/repository",serde_json::json!("https://github.com/other/repo")),
2104            ("/sdk/plugin_abi",serde_json::json!(99)),
2105            ("/targets/x86_64-pc-windows-msvc/sha256",serde_json::json!("0".repeat(64))),
2106            ("/targets/x86_64-pc-windows-msvc/library/sha256",serde_json::json!("0".repeat(64))),
2107            ("/targets/x86_64-pc-windows-msvc/library/path",serde_json::json!("../custom_runtime.dll")),
2108            ("/targets/x86_64-pc-windows-msvc/url",serde_json::json!("https://github.com/other/repo/releases/download/independent-plugin-v1.2.3/package.zip")),
2109            ("/targets/x86_64-pc-windows-msvc/size",serde_json::json!(archive.len()+1)),
2110        ];
2111        for (pointer, value) in mutations {
2112            let mut bad = good.clone();
2113            *bad.pointer_mut(pointer).unwrap() = value;
2114            let bad_manifest = serde_json::from_value(bad.clone()).unwrap();
2115            assert!(
2116                install_archive_at(
2117                    &package,
2118                    &bad_manifest,
2119                    &serde_json::to_vec(&bad).unwrap(),
2120                    &archive,
2121                    target,
2122                    agent.path(),
2123                    true
2124                )
2125                .is_err(),
2126                "{pointer}"
2127            );
2128            assert_eq!(std::fs::read(&path).unwrap(), object(target));
2129            assert_eq!(
2130                std::fs::read(agent.path().join("native-packages.json")).unwrap(),
2131                original_record
2132            );
2133        }
2134        assert!(install_archive_at(
2135            &package,
2136            &manifest,
2137            &[],
2138            &archive,
2139            TARGETS[1],
2140            agent.path(),
2141            true
2142        )
2143        .is_err());
2144    }
2145
2146    #[test]
2147    fn same_abi_but_wrong_binary_architecture_is_rejected_before_activation() {
2148        let (package, mut value, _) = fixture(TARGETS[0]);
2149        let wrong = object(TARGETS[2]);
2150        let archive = zip(&[("custom_runtime.dll", &wrong)]);
2151        let distribution = value
2152            .pointer_mut("/targets/x86_64-pc-windows-msvc")
2153            .unwrap();
2154        distribution["size"] = serde_json::json!(archive.len());
2155        distribution["sha256"] = serde_json::json!(hash(&archive));
2156        distribution["library"]["sha256"] = serde_json::json!(hash(&wrong));
2157        let manifest = serde_json::from_value(value).unwrap();
2158        let agent = tempfile::tempdir().unwrap();
2159        assert!(install_archive_at(
2160            &package,
2161            &manifest,
2162            &[],
2163            &archive,
2164            TARGETS[0],
2165            agent.path(),
2166            false
2167        )
2168        .unwrap_err()
2169        .contains("shared library"));
2170        assert!(!agent.path().join("native-packages.json").exists());
2171        assert!(!agent.path().join("extensions").exists());
2172    }
2173
2174    #[test]
2175    fn archives_cannot_traverse_retain_links_or_substitute_another_library() {
2176        let library = object(TARGETS[0]);
2177        assert!(
2178            extract_library(&zip(&[("../escape.dll", &library)]), "zip", "escape.dll").is_err()
2179        );
2180        assert!(
2181            extract_library(&zip(&[("C:\\escape.dll", &library)]), "zip", "escape.dll").is_err()
2182        );
2183        assert!(extract_library(&zip(&[("other.dll", &library)]), "zip", "declared.dll").is_err());
2184        let mut linked = zip::ZipWriter::new(Cursor::new(Vec::new()));
2185        linked
2186            .add_symlink(
2187                "declared.dll",
2188                "outside.dll",
2189                zip::write::SimpleFileOptions::default(),
2190            )
2191            .unwrap();
2192        assert!(extract_library(
2193            &linked.finish().unwrap().into_inner(),
2194            "zip",
2195            "declared.dll"
2196        )
2197        .is_err());
2198        let mut tar = tar::Builder::new(flate2::write::GzEncoder::new(
2199            Vec::new(),
2200            flate2::Compression::default(),
2201        ));
2202        let mut header = tar::Header::new_gnu();
2203        header.set_entry_type(tar::EntryType::Link);
2204        header.set_size(0);
2205        header.set_mode(0o644);
2206        header.set_cksum();
2207        tar.append_link(&mut header, "declared.so", "outside.so")
2208            .unwrap();
2209        assert!(extract_library(
2210            &tar.into_inner().unwrap().finish().unwrap(),
2211            "tar.gz",
2212            "declared.so"
2213        )
2214        .is_err());
2215    }
2216
2217    #[test]
2218    fn object_headers_cannot_cross_platform_or_architecture() {
2219        for target in TARGETS {
2220            assert!(validate_library_target(&object(target), target).is_ok());
2221            for other in TARGETS {
2222                if other != target {
2223                    // ELF headers do not encode the libc choice, so GNU and
2224                    // musl x86_64 objects are intentionally indistinguishable
2225                    // to this architecture check. Exact target manifests still
2226                    // keep those distributions separate.
2227                    if target.starts_with("x86_64-unknown-linux-")
2228                        && other.starts_with("x86_64-unknown-linux-")
2229                    {
2230                        continue;
2231                    }
2232                    assert!(validate_library_target(&object(other), target).is_err());
2233                }
2234            }
2235        }
2236        assert!(validate_library_target(&[0u8; 8], TARGETS[0]).is_err());
2237        let mut damaged = object(TARGETS[0]);
2238        damaged[0x3c..0x40].copy_from_slice(&u32::MAX.to_le_bytes());
2239        assert!(validate_library_target(&damaged, TARGETS[0]).is_err());
2240    }
2241
2242    #[test]
2243    fn exact_versions_ignore_yanked_and_prerelease_latest() {
2244        let versions = [
2245            RegistryVersion {
2246                num: "1.0.0".into(),
2247                yanked: false,
2248            },
2249            RegistryVersion {
2250                num: "9.0.0".into(),
2251                yanked: true,
2252            },
2253            RegistryVersion {
2254                num: "2.0.0-beta.1".into(),
2255                yanked: false,
2256            },
2257        ];
2258        assert_eq!(select_version(&versions, None).unwrap(), "1.0.0");
2259        assert_eq!(
2260            select_version(&versions, Some("2.0.0-beta.1")).unwrap(),
2261            "2.0.0-beta.1"
2262        );
2263        assert!(select_version(&versions, Some("9.0.0")).is_err());
2264        assert!(select_version(&versions, Some("^1")).is_err());
2265    }
2266
2267    #[test]
2268    fn github_case_and_encoded_semver_build_metadata_preserve_release_identity() {
2269        let (mut package, mut value, _) = fixture(TARGETS[0]);
2270        package.version = "1.2.3+build.1".into();
2271        value["version"] = serde_json::json!(package.version);
2272        value["tag"] = serde_json::json!("independent-plugin-v1.2.3+build.1");
2273        value["repository"] = serde_json::json!("https://github.com/Publisher/MonoRepo");
2274        value["targets"][TARGETS[0]]["url"] = serde_json::json!("https://github.com/Publisher/MonoRepo/releases/download/independent-plugin-v1.2.3%2Bbuild.1/package.zip");
2275        let manifest = serde_json::from_value(value).unwrap();
2276        assert!(validate_manifest(&manifest, &package, TARGETS[0]).is_ok());
2277        assert_eq!(
2278            canonical_repository("Publisher/MonoRepo").unwrap(),
2279            package.repository
2280        );
2281    }
2282
2283    #[test]
2284    fn repository_urls_are_explicit_and_release_tags_use_package_versions() {
2285        assert_eq!(
2286            canonical_repository("git+https://github.com/publisher/monorepo.git").unwrap(),
2287            "https://github.com/publisher/monorepo"
2288        );
2289        assert_eq!(
2290            canonical_repository("https://github.com/publisher/monorepo/tree/main/packages/plugin")
2291                .unwrap(),
2292            "https://github.com/publisher/monorepo"
2293        );
2294        for invalid in [
2295            "https://gitlab.com/x/y",
2296            "https://user@github.com/x/y",
2297            "https://github.com/x",
2298            "https://github.com/x/y/releases/latest",
2299            "https://github.com:8443/x/y",
2300        ] {
2301            assert!(canonical_repository(invalid).is_err());
2302        }
2303        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");
2304        assert!(release_url(
2305            "https://github.com/publisher/monorepo",
2306            "plugin-two",
2307            "4.5.6",
2308            "rpi-extension.json"
2309        )
2310        .unwrap()
2311        .contains("plugin-two-v4.5.6"));
2312    }
2313
2314    fn one_response(body: Vec<u8>) -> (String, std::thread::JoinHandle<String>) {
2315        response_with_status("200 OK", body)
2316    }
2317
2318    fn response_with_status(
2319        status: &'static str,
2320        body: Vec<u8>,
2321    ) -> (String, std::thread::JoinHandle<String>) {
2322        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2323        let url = format!("http://{}", listener.local_addr().unwrap());
2324        let thread = std::thread::spawn(move || {
2325            let (mut socket, _) = listener.accept().unwrap();
2326            socket
2327                .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2328                .unwrap();
2329            let mut request = Vec::new();
2330            let mut byte = [0];
2331            while !request.ends_with(b"\r\n\r\n") {
2332                socket.read_exact(&mut byte).unwrap();
2333                request.push(byte[0]);
2334            }
2335            write!(
2336                socket,
2337                "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2338                body.len()
2339            )
2340            .unwrap();
2341            socket.write_all(&body).unwrap();
2342            String::from_utf8(request).unwrap()
2343        });
2344        (url, thread)
2345    }
2346
2347    #[tokio::test]
2348    async fn downloaded_manifest_and_archive_install_into_an_isolated_profile() {
2349        let target = host_target().unwrap();
2350        let (package, value, archive) = fixture(target);
2351        let raw = serde_json::to_vec(&value).unwrap();
2352        let client = reqwest::Client::builder().no_proxy().build().unwrap();
2353        let (url, server) = one_response(raw.clone());
2354        let response = client.get(url).send().await.unwrap();
2355        let (manifest, received) = read_manifest_response(response, &package).await.unwrap();
2356        server.join().unwrap();
2357        assert_eq!(received, raw);
2358        let distribution = validate_manifest(&manifest, &package, target).unwrap();
2359        let (url, server) = one_response(archive);
2360        let response = client.get(url).send().await.unwrap();
2361        let downloaded = body_limited(response, distribution.size).await.unwrap();
2362        server.join().unwrap();
2363        let profile = tempfile::tempdir().unwrap();
2364        let paths = install_archive_at(
2365            &package,
2366            &manifest,
2367            &received,
2368            &downloaded,
2369            target,
2370            profile.path(),
2371            false,
2372        )
2373        .unwrap();
2374        assert_eq!(std::fs::read(&paths[0]).unwrap(), object(target));
2375        assert!(profile.path().join("native-packages.json").is_file());
2376    }
2377
2378    #[tokio::test]
2379    async fn missing_invalid_and_oversized_manifests_fail_before_installation() {
2380        let (package, _, _) = fixture(TARGETS[0]);
2381        let client = reqwest::Client::builder().no_proxy().build().unwrap();
2382        for (status, body, expected) in [
2383            (
2384                "404 Not Found",
2385                b"missing".to_vec(),
2386                "rpi-extension.json is missing",
2387            ),
2388            ("403 Forbidden", b"denied".to_vec(), "403 Forbidden"),
2389            ("200 OK", b"not json".to_vec(), "invalid rpi-extension.json"),
2390            (
2391                "200 OK",
2392                vec![b' '; MAX_MANIFEST as usize + 1],
2393                "size limit",
2394            ),
2395        ] {
2396            let (url, server) = response_with_status(status, body);
2397            let response = client.get(url).send().await.unwrap();
2398            let error = read_manifest_response(response, &package)
2399                .await
2400                .err()
2401                .unwrap();
2402            assert!(error.contains(expected), "{error}");
2403            server.join().unwrap();
2404        }
2405    }
2406
2407    #[tokio::test]
2408    async fn explicit_repository_and_version_do_not_require_a_registry_record() {
2409        let client = reqwest::Client::new();
2410        let resolved = resolve_package(
2411            &client,
2412            "http://127.0.0.1:1",
2413            "private-extension",
2414            Some("2.3.4"),
2415            Some("https://github.com/custom-owner/custom-repo.git"),
2416        )
2417        .await
2418        .unwrap();
2419        assert_eq!(
2420            resolved.repository,
2421            "https://github.com/custom-owner/custom-repo"
2422        );
2423        assert_eq!(resolved.name, "private-extension");
2424        assert_eq!(resolved.version, "2.3.4");
2425        assert!(release_url(
2426            &resolved.repository,
2427            &resolved.name,
2428            &resolved.version,
2429            "rpi-extension.json"
2430        )
2431        .unwrap()
2432        .contains("custom-repo/releases/download/private-extension-v2.3.4/"));
2433    }
2434
2435    #[tokio::test]
2436    async fn custom_repository_versions_are_scoped_to_the_package() {
2437        let refs = serde_json::to_vec(&serde_json::json!([
2438            {"ref":"refs/tags/independent-plugin-v1.2.3"},
2439            {"ref":"refs/tags/independent-plugin-v1.4.0"},
2440            {"ref":"refs/tags/other-plugin-v99.0.0"},
2441            {"ref":"refs/tags/independent-plugin-v2.0.0-beta.1"}
2442        ]))
2443        .unwrap();
2444        let (api, server) = one_response(refs);
2445        let client = reqwest::Client::builder().no_proxy().build().unwrap();
2446        assert_eq!(
2447            repository_version(
2448                &client,
2449                &api,
2450                "independent-plugin",
2451                "https://github.com/publisher/monorepo"
2452            )
2453            .await
2454            .unwrap(),
2455            "1.4.0"
2456        );
2457        assert!(server.join().unwrap().starts_with(
2458            "GET /repos/publisher/monorepo/git/matching-refs/tags/independent-plugin-v "
2459        ));
2460    }
2461
2462    #[tokio::test]
2463    async fn registry_discovery_uses_repository_metadata_and_downloads_are_bounded() {
2464        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();
2465        let (url, server) = one_response(payload);
2466        let client = reqwest::Client::builder().no_proxy().build().unwrap();
2467        let package = resolve_package(&client, &url, "independent-plugin", None, None)
2468            .await
2469            .unwrap();
2470        assert_eq!(package.repository, "https://github.com/publisher/monorepo");
2471        assert_eq!(package.version, "1.2.3");
2472        assert!(server
2473            .join()
2474            .unwrap()
2475            .starts_with("GET /independent-plugin "));
2476        let (url, server) = one_response(vec![0u8; 32]);
2477        let response = client.get(url).send().await.unwrap();
2478        assert!(body_limited(response, 8).await.is_err());
2479        server.join().unwrap();
2480    }
2481}