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