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