1use 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;
18const RUNTIME_ASSETS_FILE: &str = "runtime-assets.json";
21const MAX_ASSET_INDEX: u64 = 256 * 1024;
23const 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 #[serde(default)]
38 pub custom_repository: bool,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub assets: Vec<PrebuiltAsset>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct PrebuiltAsset {
48 pub path: String,
49 pub sha256: String,
50}
51
52#[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#[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
80pub(crate) struct VerifiedAsset {
82 pub path: String,
83 pub sha256: String,
84 pub bytes: Vec<u8>,
85}
86
87pub(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
192pub 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
215pub(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 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
543pub 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 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 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
745fn 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
821pub(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
841pub(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
851fn 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 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
962fn 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 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
1017fn 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
1045pub(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 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
1089pub(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
1109fn 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 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 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 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 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 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 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 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 let contents = extract_contents(&zip(&entries), "zip", "custom_runtime.dll").unwrap();
1691 assert_eq!(contents.assets.len(), 126);
1692
1693 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 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 understate_entry(&mut archive, "runtime/main.mjs", 1);
1755
1756 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 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(®istry).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}