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;
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20pub struct PrebuiltRecord {
21 pub repository: String,
22 pub target: String,
23 pub manifest_sha256: String,
24 pub source_commit: String,
25 pub sdk_version: String,
26 pub plugin_abi: u32,
27 pub archive_sha256: String,
28 pub library_sha256: String,
29 #[serde(default)]
31 pub custom_repository: bool,
32}
33
34#[derive(Debug, Deserialize)]
35struct RegistryPackage {
36 #[serde(rename = "crate")]
37 metadata: RegistryMetadata,
38 versions: Vec<RegistryVersion>,
39}
40
41#[derive(Debug, Deserialize)]
42struct RegistryMetadata {
43 id: String,
44 repository: Option<String>,
45}
46
47#[derive(Debug, Deserialize)]
48struct RegistryVersion {
49 num: String,
50 yanked: bool,
51}
52
53#[derive(Debug, Deserialize)]
54struct Manifest {
55 schema_version: u32,
56 name: String,
57 version: String,
58 repository: String,
59 tag: String,
60 source_commit: String,
61 sdk: Sdk,
62 targets: BTreeMap<String, Distribution>,
63}
64
65#[derive(Debug, Deserialize)]
66struct Sdk {
67 version: String,
68 plugin_abi: u32,
69}
70
71#[derive(Debug, Deserialize)]
72struct Distribution {
73 url: String,
74 format: String,
75 size: u64,
76 sha256: String,
77 library: Library,
78}
79
80#[derive(Debug, Deserialize)]
81struct Library {
82 path: String,
83 sha256: String,
84}
85
86struct ResolvedPackage {
87 name: String,
88 version: String,
89 repository: String,
90 custom_repository: bool,
91}
92
93#[derive(Deserialize)]
94struct GitReference {
95 #[serde(rename = "ref")]
96 reference: String,
97}
98
99fn stable_tag_version(refs: &[GitReference], name: &str) -> Result<String, String> {
100 let prefix = format!("refs/tags/{name}-v");
101 refs.iter().filter_map(|r| r.reference.strip_prefix(&prefix))
102 .filter_map(|v| semver::Version::parse(v).ok()).filter(|v| v.pre.is_empty())
103 .max().map(|v| v.to_string()).ok_or_else(|| format!("no stable {name}-v<version> tag exists in this repository; specify --version explicitly"))
104}
105
106async fn repository_version(
107 client: &reqwest::Client,
108 api: &str,
109 name: &str,
110 repository: &str,
111) -> Result<String, String> {
112 let repository = canonical_repository(repository)?;
113 let repo = repository
114 .strip_prefix("https://github.com/")
115 .ok_or("invalid GitHub repository")?;
116 let response = client
117 .get(format!(
118 "{api}/repos/{repo}/git/matching-refs/tags/{name}-v"
119 ))
120 .header(reqwest::header::ACCEPT, "application/vnd.github+json")
121 .send()
122 .await
123 .map_err(|e| format!("GitHub tag lookup failed: {e}"))?;
124 if !response.status().is_success() {
125 return Err(format!("GitHub tag lookup returned {}", response.status()));
126 }
127 let refs: Vec<GitReference> =
128 serde_json::from_slice(&body_limited(response, MAX_METADATA).await?)
129 .map_err(|e| format!("invalid GitHub tag metadata: {e}"))?;
130 stable_tag_version(&refs, name)
131}
132
133pub fn host_target() -> Result<&'static str, String> {
136 match (std::env::consts::OS, std::env::consts::ARCH) {
137 ("windows", "x86_64") if cfg!(target_env = "msvc") => Ok("x86_64-pc-windows-msvc"),
138 ("linux", "x86_64") if cfg!(target_env = "gnu") => Ok("x86_64-unknown-linux-gnu"),
139 ("linux", "aarch64") if cfg!(target_env = "gnu") => Ok("aarch64-unknown-linux-gnu"),
140 ("macos", "x86_64") => Ok("x86_64-apple-darwin"),
141 ("macos", "aarch64") => Ok("aarch64-apple-darwin"),
142 _ => Err(
143 "no supported prebuilt target for this host; use --build to compile explicitly".into(),
144 ),
145 }
146}
147
148fn valid_segment(value: &str) -> bool {
149 !value.is_empty()
150 && !matches!(value, "." | "..")
151 && value
152 .bytes()
153 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
154}
155
156pub(crate) fn canonical_repository(value: &str) -> Result<String, String> {
159 let value = value.trim().strip_prefix("git+").unwrap_or(value.trim());
160 let value = if value.contains("://") {
161 value.to_string()
162 } else {
163 format!("https://github.com/{value}")
164 };
165 let url = reqwest::Url::parse(&value).map_err(|_| "invalid GitHub repository".to_string())?;
166 if url.scheme() != "https"
167 || url.host_str() != Some("github.com")
168 || !url.username().is_empty()
169 || url.password().is_some()
170 || url.port().is_some()
171 {
172 return Err("prebuilt repository must be an HTTPS GitHub repository; use --build for other repositories".into());
173 }
174 let segments: Vec<_> = url
175 .path_segments()
176 .ok_or("invalid GitHub repository path")?
177 .collect();
178 if segments.len() < 2 || !valid_segment(segments[0]) {
179 return Err("repository must identify a GitHub owner and repository".into());
180 }
181 let repo = segments[1].strip_suffix(".git").unwrap_or(segments[1]);
182 if !valid_segment(repo) {
183 return Err("invalid GitHub repository name".into());
184 }
185 if segments.len() > 2
186 && !segments[2..].iter().all(|s| s.is_empty())
187 && !matches!(segments[2], "tree" | "blob")
188 {
189 return Err("unsupported GitHub repository URL path".into());
190 }
191 Ok(format!(
192 "https://github.com/{}/{}",
193 segments[0].to_ascii_lowercase(),
194 repo.to_ascii_lowercase()
195 ))
196}
197
198fn release_url(repository: &str, name: &str, version: &str, file: &str) -> Result<String, String> {
199 let mut url = reqwest::Url::parse(repository).map_err(|e| e.to_string())?;
200 url.path_segments_mut()
201 .map_err(|_| "invalid release repository")?
202 .push("releases")
203 .push("download")
204 .push(&format!("{name}-v{version}"))
205 .push(file);
206 Ok(url.into())
207}
208
209fn select_version(versions: &[RegistryVersion], requested: Option<&str>) -> Result<String, String> {
210 if let Some(requested) = requested {
211 let version = semver::Version::parse(requested)
212 .map_err(|_| "--version requires an exact semantic version".to_string())?;
213 return versions
214 .iter()
215 .find(|v| !v.yanked && v.num == version.to_string())
216 .map(|v| v.num.clone())
217 .ok_or_else(|| format!("version {requested} is absent or yanked on crates.io"));
218 }
219 versions
220 .iter()
221 .filter(|v| !v.yanked)
222 .filter_map(|v| semver::Version::parse(&v.num).ok())
223 .filter(|v| v.pre.is_empty())
224 .max()
225 .map(|v| v.to_string())
226 .ok_or_else(|| "no non-yanked stable version is available".into())
227}
228
229pub(crate) async fn body_limited(
230 mut response: reqwest::Response,
231 limit: u64,
232) -> Result<Vec<u8>, String> {
233 if response.content_length().is_some_and(|n| n > limit) {
234 return Err("download exceeds size limit".into());
235 }
236 let mut bytes = Vec::new();
237 while let Some(chunk) = response
238 .chunk()
239 .await
240 .map_err(|e| format!("download failed: {e}"))?
241 {
242 if bytes.len() as u64 + chunk.len() as u64 > limit {
243 return Err("download exceeds size limit".into());
244 }
245 bytes.extend_from_slice(&chunk);
246 }
247 Ok(bytes)
248}
249
250async fn resolve_package(
251 client: &reqwest::Client,
252 api: &str,
253 name: &str,
254 version: Option<&str>,
255 repository: Option<&str>,
256) -> Result<ResolvedPackage, String> {
257 if let Some(repository) = repository {
259 let repository = canonical_repository(repository)?;
260 let version = match version {
261 Some(version) => semver::Version::parse(version)
262 .map_err(|_| "--version requires an exact semantic version".to_string())?
263 .to_string(),
264 None => repository_version(client, "https://api.github.com", name, &repository).await?,
265 };
266 return Ok(ResolvedPackage {
267 name: name.into(),
268 version,
269 repository,
270 custom_repository: true,
271 });
272 }
273 let response = client
274 .get(format!("{api}/{name}"))
275 .send()
276 .await
277 .map_err(|e| format!("crates.io lookup failed: {e}"))?;
278 if !response.status().is_success() {
279 return Err(format!(
280 "crates.io lookup for {name} returned {}",
281 response.status()
282 ));
283 }
284 let data: RegistryPackage =
285 serde_json::from_slice(&body_limited(response, MAX_METADATA).await?)
286 .map_err(|e| format!("invalid crates.io metadata: {e}"))?;
287 if !data
288 .metadata
289 .id
290 .replace('-', "_")
291 .eq_ignore_ascii_case(&name.replace('-', "_"))
292 {
293 return Err("crates.io metadata identifies a different package".into());
294 }
295 let version = select_version(&data.versions, version)?;
296 let repository = repository
297 .or(data.metadata.repository.as_deref())
298 .ok_or("crate has no repository metadata; use --github owner/repo or --build")?;
299 Ok(ResolvedPackage {
300 name: data.metadata.id,
301 version,
302 repository: canonical_repository(repository)?,
303 custom_repository: false,
304 })
305}
306
307pub(crate) fn hash(bytes: &[u8]) -> String {
308 format!("{:x}", Sha256::digest(bytes))
309}
310
311pub(crate) fn check_hash(bytes: &[u8], expected: &str) -> Result<(), String> {
312 if expected.len() != 64
313 || !expected
314 .bytes()
315 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
316 {
317 return Err("manifest SHA-256 must be 64 lowercase hexadecimal characters".into());
318 }
319 if hash(bytes) != expected {
320 return Err("SHA-256 mismatch".into());
321 }
322 Ok(())
323}
324
325fn validate_manifest<'a>(
326 manifest: &'a Manifest,
327 package: &ResolvedPackage,
328 target: &str,
329) -> Result<&'a Distribution, String> {
330 if manifest.schema_version != 1 {
331 return Err(format!(
332 "unsupported prebuilt manifest schema {}",
333 manifest.schema_version
334 ));
335 }
336 if manifest.name != package.name
337 || manifest.version != package.version
338 || manifest.tag != format!("{}-v{}", package.name, package.version)
339 || canonical_repository(&manifest.repository)? != package.repository
340 {
341 return Err("prebuilt manifest package, version, repository or tag does not match the requested package".into());
342 }
343 if manifest.source_commit.len() != 40
344 || !manifest
345 .source_commit
346 .bytes()
347 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
348 {
349 return Err("manifest source_commit must be a full Git commit SHA".into());
350 }
351 semver::Version::parse(&manifest.sdk.version).map_err(|_| "invalid SDK version in manifest")?;
352 if manifest.sdk.plugin_abi != rpi_plugin_sdk::RPI_PLUGIN_ABI_VERSION_UNIFIED {
353 return Err(format!(
354 "extension SDK ABI {} is incompatible with host ABI {}",
355 manifest.sdk.plugin_abi,
356 rpi_plugin_sdk::RPI_PLUGIN_ABI_VERSION_UNIFIED
357 ));
358 }
359 let distribution = manifest.targets.get(target).ok_or_else(|| {
360 format!(
361 "no prebuilt extension for exact target {target}; use --build to compile explicitly"
362 )
363 })?;
364 if distribution.size == 0
365 || distribution.size > MAX_ARCHIVE
366 || !matches!(distribution.format.as_str(), "zip" | "tar.gz")
367 {
368 return Err("unsupported archive format or size in manifest".into());
369 }
370 let name = &distribution.library.path;
371 let extension = if target.contains("windows") {
372 ".dll"
373 } else if target.contains("apple") {
374 ".dylib"
375 } else {
376 ".so"
377 };
378 if name.is_empty()
379 || !name.ends_with(extension)
380 || name.contains(['/', '\\', ':'])
381 || !name
382 .bytes()
383 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
384 || matches!(name.as_str(), "." | "..")
385 {
386 return Err(
387 "manifest library.path must be a flat library filename for the selected target".into(),
388 );
389 }
390 for expected in [&distribution.sha256, &distribution.library.sha256] {
391 if expected.len() != 64
392 || !expected
393 .bytes()
394 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
395 {
396 return Err("invalid SHA-256 in manifest".into());
397 }
398 }
399 validate_release_asset_url(&distribution.url, &package.repository, &manifest.tag)?;
400 Ok(distribution)
401}
402
403pub(crate) fn validate_release_asset_url(
404 value: &str,
405 repository: &str,
406 tag: &str,
407) -> Result<(), String> {
408 let url = reqwest::Url::parse(value).map_err(|_| "invalid archive URL")?;
409 let segments: Vec<String> = url
410 .path_segments()
411 .ok_or("archive URL has no path")?
412 .map(|s| {
413 percent_encoding::percent_decode_str(s)
414 .decode_utf8()
415 .map(|s| s.into_owned())
416 .map_err(|_| "invalid encoded archive URL")
417 })
418 .collect::<Result<_, _>>()?;
419 if url.scheme() != "https"
420 || url.host_str() != Some("github.com")
421 || url.port().is_some()
422 || !url.username().is_empty()
423 || url.password().is_some()
424 || url.query().is_some()
425 || url.fragment().is_some()
426 || segments.len() != 6
427 || segments[2] != "releases"
428 || segments[3] != "download"
429 || segments[4] != tag
430 || canonical_repository(&format!(
431 "https://github.com/{}/{}",
432 segments[0], segments[1]
433 ))? != repository
434 || segments[5].is_empty()
435 || matches!(segments[5].as_str(), "." | "..")
436 || !segments[5]
437 .bytes()
438 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'+'))
439 {
440 return Err(
441 "archive URL must belong to the same GitHub repository and package Release".into(),
442 );
443 }
444 Ok(())
445}
446
447async fn fetch_manifest(
448 client: &reqwest::Client,
449 package: &ResolvedPackage,
450) -> Result<(Manifest, Vec<u8>), String> {
451 let url = release_url(
452 &package.repository,
453 &package.name,
454 &package.version,
455 "rpi-extension.json",
456 )?;
457 let response = client
458 .get(&url)
459 .send()
460 .await
461 .map_err(|e| format!("prebuilt manifest request failed: {e}"))?;
462 read_manifest_response(response, package).await
463}
464
465async fn read_manifest_response(
466 response: reqwest::Response,
467 package: &ResolvedPackage,
468) -> Result<(Manifest, Vec<u8>), String> {
469 if response.status() == reqwest::StatusCode::NOT_FOUND {
470 return Err(format!("prebuilt release for {} {} is not ready (rpi-extension.json is missing); retry later or use --build explicitly", package.name, package.version));
471 }
472 if !response.status().is_success() {
473 return Err(format!(
474 "prebuilt manifest request returned {}",
475 response.status()
476 ));
477 }
478 let bytes = body_limited(response, MAX_MANIFEST).await?;
479 let manifest =
480 serde_json::from_slice(&bytes).map_err(|e| format!("invalid rpi-extension.json: {e}"))?;
481 Ok((manifest, bytes))
482}
483
484pub async fn latest_compatible_version(
486 client: &reqwest::Client,
487 package: &crate::install::InstalledNativePackage,
488) -> Result<Option<String>, String> {
489 let Some(record) = &package.prebuilt else {
490 return Ok(None);
491 };
492 let repository = record
493 .custom_repository
494 .then_some(record.repository.as_str());
495 let resolved = resolve_package(client, CRATES_API, &package.name, None, repository).await?;
496 if resolved.repository != record.repository {
497 return Err(
498 "extension repository changed; reinstall explicitly to select a new publisher".into(),
499 );
500 }
501 if !crate::updates::is_newer(&package.version, &resolved.version) {
502 return Ok(Some(resolved.version));
503 }
504 let (manifest, _) = fetch_manifest(client, &resolved).await?;
505 validate_manifest(&manifest, &resolved, host_target()?)?;
506 Ok(Some(resolved.version))
507}
508
509pub async fn install(
510 name: &str,
511 version: Option<&str>,
512 repository: Option<&str>,
513 force: bool,
514) -> Result<(), String> {
515 let records = crate::install::installed_native_packages_strict()?;
516 let existing = records.iter().find(|p| p.name == name);
517 if existing.is_some() && !force {
518 return Err(format!(
519 "{name} is already installed; use --force to replace it"
520 ));
521 }
522 let target = host_target()?;
523 let client = reqwest::Client::builder()
524 .timeout(std::time::Duration::from_secs(15))
525 .user_agent(format!("rpi/{}", crate::VERSION))
526 .build()
527 .map_err(|e| e.to_string())?;
528 let repository = repository.or_else(|| {
529 existing
530 .and_then(|p| p.prebuilt.as_ref())
531 .filter(|record| record.custom_repository)
532 .map(|r| r.repository.as_str())
533 });
534 let package = resolve_package(&client, CRATES_API, name, version, repository).await?;
535 if let Some(record) = existing.and_then(|p| p.prebuilt.as_ref()) {
536 if repository.is_none() && record.repository != package.repository {
537 return Err(
538 "extension repository changed; select the new repository explicitly with --github"
539 .into(),
540 );
541 }
542 }
543 println!(
544 "downloading prebuilt {} {} for {target}",
545 package.name, package.version
546 );
547 let (manifest, manifest_bytes) = fetch_manifest(&client, &package).await?;
548 let distribution = validate_manifest(&manifest, &package, target)?;
549 let response = client
550 .get(&distribution.url)
551 .timeout(std::time::Duration::from_secs(180))
552 .send()
553 .await
554 .map_err(|e| format!("archive request failed: {e}"))?;
555 if !response.status().is_success() {
556 return Err(format!("archive request returned {}", response.status()));
557 }
558 let archive = body_limited(response, distribution.size).await?;
559 let agent_dir = crate::config::agent_dir().map_err(|e| e.to_string())?;
560 let target = target.to_string();
561 let installed = tokio::task::spawn_blocking(move || {
562 install_archive_at(
563 &package,
564 &manifest,
565 &manifest_bytes,
566 &archive,
567 &target,
568 &agent_dir,
569 force,
570 )
571 })
572 .await
573 .map_err(|e| format!("prebuilt installation failed: {e}"))??;
574 for path in installed {
575 println!("installed {}", path.display());
576 }
577 println!("rpi will validate the plugin entrypoint/SDK API size and load this extension on the next start.");
578 Ok(())
579}
580
581fn install_archive_at(
582 package: &ResolvedPackage,
583 manifest: &Manifest,
584 manifest_bytes: &[u8],
585 archive: &[u8],
586 target: &str,
587 agent_dir: &Path,
588 force: bool,
589) -> Result<Vec<PathBuf>, String> {
590 let distribution = validate_manifest(manifest, package, target)?;
591 if archive.len() as u64 != distribution.size {
592 return Err("archive byte length does not match manifest".into());
593 }
594 check_hash(archive, &distribution.sha256)?;
595 let bytes = extract_library(archive, &distribution.format, &distribution.library.path)?;
596 check_hash(&bytes, &distribution.library.sha256)?;
597 validate_library_target(&bytes, target)?;
598 let provenance = PrebuiltRecord {
599 repository: package.repository.clone(),
600 target: target.into(),
601 manifest_sha256: hash(manifest_bytes),
602 source_commit: manifest.source_commit.clone(),
603 sdk_version: manifest.sdk.version.clone(),
604 plugin_abi: manifest.sdk.plugin_abi,
605 archive_sha256: distribution.sha256.clone(),
606 library_sha256: distribution.library.sha256.clone(),
607 custom_repository: package.custom_repository,
608 };
609 let temporary = tempfile::tempdir().map_err(|e| e.to_string())?;
610 let artifact = temporary.path().join(&distribution.library.path);
611 std::fs::write(&artifact, bytes).map_err(|e| e.to_string())?;
612 let record = crate::install::InstalledNativePackage {
613 name: package.name.clone(),
614 version: package.version.clone(),
615 source: Some(format!("prebuilt:{}", package.repository)),
617 prebuilt: Some(provenance),
618 artifacts: Vec::new(),
619 };
620 crate::install::install_native_package_at(
621 &agent_dir.join("extensions"),
622 &agent_dir.join("native-packages.json"),
623 record,
624 &[artifact],
625 force,
626 )
627}
628
629fn safe_entry_name(name: &str) -> Result<PathBuf, String> {
630 if name.contains(['\\', ':'])
631 || Path::new(name).components().any(|p| {
632 matches!(
633 p,
634 Component::ParentDir | Component::RootDir | Component::Prefix(_)
635 )
636 })
637 {
638 return Err("unsafe archive entry path".into());
639 }
640 Ok(Path::new(name)
641 .components()
642 .filter(|c| !matches!(c, Component::CurDir))
643 .collect())
644}
645
646fn read_library(reader: impl Read) -> Result<Vec<u8>, String> {
647 let mut bytes = Vec::new();
648 reader
649 .take(MAX_LIBRARY + 1)
650 .read_to_end(&mut bytes)
651 .map_err(|e| e.to_string())?;
652 if bytes.len() as u64 > MAX_LIBRARY {
653 return Err("extracted library exceeds size limit".into());
654 }
655 Ok(bytes)
656}
657
658pub(crate) fn extract_library(
659 archive: &[u8],
660 format: &str,
661 library: &str,
662) -> Result<Vec<u8>, String> {
663 let mut found = None;
664 let mut total = 0u64;
665 match format {
666 "zip" => {
667 let mut zip = zip::ZipArchive::new(Cursor::new(archive)).map_err(|e| e.to_string())?;
668 if zip.len() > MAX_ENTRIES {
669 return Err("too many archive entries".into());
670 }
671 for i in 0..zip.len() {
672 let file = zip.by_index(i).map_err(|e| e.to_string())?;
673 let path = safe_entry_name(file.name())?;
674 if file
675 .unix_mode()
676 .is_some_and(|mode| mode & 0o170000 == 0o120000)
677 {
678 return Err("archive symlinks are not allowed".into());
679 }
680 total = total
681 .checked_add(file.size())
682 .ok_or("archive size overflow")?;
683 if total > MAX_ARCHIVE {
684 return Err("expanded archive exceeds size limit".into());
685 }
686 if !file.is_dir() && path == Path::new(library) {
687 if found.is_some() {
688 return Err("duplicate declared library in archive".into());
689 }
690 found = Some(read_library(file)?);
691 }
692 }
693 }
694 "tar.gz" => {
695 let decoder = flate2::read::GzDecoder::new(Cursor::new(archive)).take(MAX_ARCHIVE + 1);
696 let mut tar = tar::Archive::new(decoder);
697 for (i, entry) in tar.entries().map_err(|e| e.to_string())?.enumerate() {
698 if i >= MAX_ENTRIES {
699 return Err("too many archive entries".into());
700 }
701 let file = entry.map_err(|e| e.to_string())?;
702 let name = file
703 .path()
704 .map_err(|e| e.to_string())?
705 .to_string_lossy()
706 .into_owned();
707 let path = safe_entry_name(&name)?;
708 let kind = file.header().entry_type();
709 if !kind.is_file() && !kind.is_dir() {
710 return Err("archive links and special files are not allowed".into());
711 }
712 total = total
713 .checked_add(file.size())
714 .ok_or("archive size overflow")?;
715 if total > MAX_ARCHIVE {
716 return Err("expanded archive exceeds size limit".into());
717 }
718 if kind.is_file() && path == Path::new(library) {
719 if found.is_some() {
720 return Err("duplicate declared library in archive".into());
721 }
722 found = Some(read_library(file)?);
723 }
724 }
725 }
726 _ => return Err("unsupported prebuilt archive format".into()),
727 }
728 found.ok_or_else(|| format!("archive does not contain the exact declared library {library}"))
729}
730
731fn validate_library_target(bytes: &[u8], target: &str) -> Result<(), String> {
733 validate_native_target(bytes, target, true)
734}
735
736pub(crate) fn validate_executable_target(bytes: &[u8], target: &str) -> Result<(), String> {
737 validate_native_target(bytes, target, false)
738}
739
740fn validate_native_target(bytes: &[u8], target: &str, library: bool) -> Result<(), String> {
741 let u16_at = |n: usize| {
742 bytes
743 .get(n..n.checked_add(2)?)
744 .map(|b| u16::from_le_bytes([b[0], b[1]]))
745 };
746 let u32_at = |n: usize| {
747 bytes
748 .get(n..n.checked_add(4)?)
749 .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
750 };
751 let valid = match target {
752 "x86_64-pc-windows-msvc" => {
753 let offset = u32_at(0x3c).unwrap_or(u32::MAX) as usize;
754 bytes.starts_with(b"MZ")
755 && bytes.get(offset..offset.saturating_add(4)) == Some(b"PE\0\0".as_slice())
756 && u16_at(offset.saturating_add(4)) == Some(0x8664)
757 && u16_at(offset.saturating_add(22)).is_some_and(|flags| {
758 if library {
759 flags & 0x2000 != 0
760 } else {
761 flags & 0x2000 == 0 && flags & 2 != 0
762 }
763 })
764 }
765 "x86_64-unknown-linux-gnu" | "aarch64-unknown-linux-gnu" => {
766 bytes.starts_with(b"\x7fELF\x02\x01")
767 && u16_at(16).is_some_and(|kind| kind == 3 || (!library && kind == 2))
768 && u16_at(18)
769 == Some(if target.starts_with("aarch64") {
770 183
771 } else {
772 62
773 })
774 }
775 "x86_64-apple-darwin" | "aarch64-apple-darwin" => {
776 u32_at(0) == Some(0xfeedfacf)
777 && u32_at(12) == Some(if library { 6 } else { 2 })
778 && u32_at(4)
779 == Some(if target.starts_with("aarch64") {
780 0x0100000c
781 } else {
782 0x01000007
783 })
784 }
785 _ => false,
786 };
787 if !valid {
788 return Err(format!(
789 "declared artifact is not a compatible native {} for {target}",
790 if library {
791 "shared library"
792 } else {
793 "executable"
794 }
795 ));
796 }
797 Ok(())
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803 use std::io::Write;
804
805 const TARGETS: [&str; 5] = [
806 "x86_64-pc-windows-msvc",
807 "x86_64-unknown-linux-gnu",
808 "aarch64-unknown-linux-gnu",
809 "x86_64-apple-darwin",
810 "aarch64-apple-darwin",
811 ];
812
813 fn object(target: &str) -> Vec<u8> {
814 let mut bytes = vec![0u8; 128];
815 if target.contains("windows") {
816 bytes[..2].copy_from_slice(b"MZ");
817 bytes[0x3c..0x40].copy_from_slice(&64u32.to_le_bytes());
818 bytes[64..68].copy_from_slice(b"PE\0\0");
819 bytes[68..70].copy_from_slice(&0x8664u16.to_le_bytes());
820 bytes[86..88].copy_from_slice(&0x2000u16.to_le_bytes());
821 } else if target.contains("linux") {
822 bytes[..6].copy_from_slice(b"\x7fELF\x02\x01");
823 bytes[16..18].copy_from_slice(&3u16.to_le_bytes());
824 bytes[18..20].copy_from_slice(
825 &(if target.starts_with("aarch64") {
826 183u16
827 } else {
828 62u16
829 })
830 .to_le_bytes(),
831 );
832 } else {
833 bytes[..4].copy_from_slice(&0xfeedfacfu32.to_le_bytes());
834 bytes[4..8].copy_from_slice(
835 &(if target.starts_with("aarch64") {
836 0x0100000cu32
837 } else {
838 0x01000007u32
839 })
840 .to_le_bytes(),
841 );
842 bytes[12..16].copy_from_slice(&6u32.to_le_bytes());
843 }
844 bytes
845 }
846
847 fn zip(files: &[(&str, &[u8])]) -> Vec<u8> {
848 let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
849 for (name, bytes) in files {
850 zip.start_file(*name, zip::write::SimpleFileOptions::default())
851 .unwrap();
852 zip.write_all(bytes).unwrap();
853 }
854 zip.finish().unwrap().into_inner()
855 }
856
857 fn tgz(name: &str, bytes: &[u8]) -> Vec<u8> {
858 let mut tar = tar::Builder::new(flate2::write::GzEncoder::new(
859 Vec::new(),
860 flate2::Compression::default(),
861 ));
862 let mut header = tar::Header::new_gnu();
863 header.set_size(bytes.len() as u64);
864 header.set_mode(0o644);
865 header.set_cksum();
866 tar.append_data(&mut header, name, bytes).unwrap();
867 tar.into_inner().unwrap().finish().unwrap()
868 }
869
870 fn fixture(target: &str) -> (ResolvedPackage, serde_json::Value, Vec<u8>) {
871 let package = ResolvedPackage {
872 name: "independent-plugin".into(),
873 version: "1.2.3".into(),
874 repository: "https://github.com/publisher/monorepo".into(),
875 custom_repository: false,
876 };
877 let (format, library, file) = if target.contains("windows") {
879 ("zip", "custom_runtime.dll", "package.zip")
880 } else if target.contains("apple") {
881 ("tar.gz", "libcustom_runtime.dylib", "package.tar.gz")
882 } else {
883 ("tar.gz", "libcustom_runtime.so", "package.tar.gz")
884 };
885 let bytes = object(target);
886 let archive = if format == "zip" {
887 zip(&[(library, &bytes)])
888 } else {
889 tgz(library, &bytes)
890 };
891 let manifest = serde_json::json!({
892 "schema_version":1, "name":package.name, "version":package.version,
893 "repository":package.repository, "tag":"independent-plugin-v1.2.3", "source_commit":"a".repeat(40),
894 "sdk":{"version":"0.3.23", "plugin_abi":rpi_plugin_sdk::RPI_PLUGIN_ABI_VERSION_UNIFIED},
895 "targets":{target:{"url":release_url(&package.repository,&package.name,&package.version,file).unwrap(),
896 "format":format,"size":archive.len(),"sha256":hash(&archive),"library":{"path":library,"sha256":hash(&bytes)}}}
897 });
898 (package, manifest, archive)
899 }
900
901 #[test]
902 fn all_five_distributions_install_and_record_exact_provenance() {
903 for target in TARGETS {
904 let (package, value, archive) = fixture(target);
905 let manifest: Manifest = serde_json::from_value(value.clone()).unwrap();
906 let raw = serde_json::to_vec(&value).unwrap();
907 let agent = tempfile::tempdir().unwrap();
908 let paths = install_archive_at(
909 &package,
910 &manifest,
911 &raw,
912 &archive,
913 target,
914 agent.path(),
915 false,
916 )
917 .unwrap();
918 assert_eq!(paths.len(), 1);
919 assert_eq!(std::fs::read(&paths[0]).unwrap(), object(target));
920 let records: Vec<crate::install::InstalledNativePackage> = serde_json::from_slice(
921 &std::fs::read(agent.path().join("native-packages.json")).unwrap(),
922 )
923 .unwrap();
924 assert_eq!(records[0].name, "independent-plugin");
925 assert_eq!(records[0].version, "1.2.3");
926 assert_eq!(
927 records[0].artifacts,
928 [manifest.targets[target].library.path.clone()]
929 );
930 let origin = records[0].prebuilt.as_ref().unwrap();
931 assert_eq!(origin.target, target);
932 assert_eq!(origin.manifest_sha256, hash(&raw));
933 assert_eq!(origin.repository, package.repository);
934 assert_eq!(origin.archive_sha256, hash(&archive));
935 assert_eq!(origin.library_sha256, hash(&object(target)));
936 }
937 }
938
939 #[test]
940 fn bad_release_identity_hash_abi_and_target_leave_installed_version_intact() {
941 let target = TARGETS[0];
942 let (package, good, archive) = fixture(target);
943 let manifest: Manifest = serde_json::from_value(good.clone()).unwrap();
944 let agent = tempfile::tempdir().unwrap();
945 let path = install_archive_at(
946 &package,
947 &manifest,
948 &serde_json::to_vec(&good).unwrap(),
949 &archive,
950 target,
951 agent.path(),
952 false,
953 )
954 .unwrap()
955 .remove(0);
956 let original_record = std::fs::read(agent.path().join("native-packages.json")).unwrap();
957 let mutations = [
958 ("/schema_version",serde_json::json!(99)), ("/name",serde_json::json!("other-plugin")),
959 ("/version",serde_json::json!("9.9.9")), ("/tag",serde_json::json!("v1.2.3")),
960 ("/repository",serde_json::json!("https://github.com/other/repo")),
961 ("/sdk/plugin_abi",serde_json::json!(99)),
962 ("/targets/x86_64-pc-windows-msvc/sha256",serde_json::json!("0".repeat(64))),
963 ("/targets/x86_64-pc-windows-msvc/library/sha256",serde_json::json!("0".repeat(64))),
964 ("/targets/x86_64-pc-windows-msvc/library/path",serde_json::json!("../custom_runtime.dll")),
965 ("/targets/x86_64-pc-windows-msvc/url",serde_json::json!("https://github.com/other/repo/releases/download/independent-plugin-v1.2.3/package.zip")),
966 ("/targets/x86_64-pc-windows-msvc/size",serde_json::json!(archive.len()+1)),
967 ];
968 for (pointer, value) in mutations {
969 let mut bad = good.clone();
970 *bad.pointer_mut(pointer).unwrap() = value;
971 let bad_manifest = serde_json::from_value(bad.clone()).unwrap();
972 assert!(
973 install_archive_at(
974 &package,
975 &bad_manifest,
976 &serde_json::to_vec(&bad).unwrap(),
977 &archive,
978 target,
979 agent.path(),
980 true
981 )
982 .is_err(),
983 "{pointer}"
984 );
985 assert_eq!(std::fs::read(&path).unwrap(), object(target));
986 assert_eq!(
987 std::fs::read(agent.path().join("native-packages.json")).unwrap(),
988 original_record
989 );
990 }
991 assert!(install_archive_at(
992 &package,
993 &manifest,
994 &[],
995 &archive,
996 TARGETS[1],
997 agent.path(),
998 true
999 )
1000 .is_err());
1001 }
1002
1003 #[test]
1004 fn same_abi_but_wrong_binary_architecture_is_rejected_before_activation() {
1005 let (package, mut value, _) = fixture(TARGETS[0]);
1006 let wrong = object(TARGETS[2]);
1007 let archive = zip(&[("custom_runtime.dll", &wrong)]);
1008 let distribution = value
1009 .pointer_mut("/targets/x86_64-pc-windows-msvc")
1010 .unwrap();
1011 distribution["size"] = serde_json::json!(archive.len());
1012 distribution["sha256"] = serde_json::json!(hash(&archive));
1013 distribution["library"]["sha256"] = serde_json::json!(hash(&wrong));
1014 let manifest = serde_json::from_value(value).unwrap();
1015 let agent = tempfile::tempdir().unwrap();
1016 assert!(install_archive_at(
1017 &package,
1018 &manifest,
1019 &[],
1020 &archive,
1021 TARGETS[0],
1022 agent.path(),
1023 false
1024 )
1025 .unwrap_err()
1026 .contains("shared library"));
1027 assert!(!agent.path().join("native-packages.json").exists());
1028 assert!(!agent.path().join("extensions").exists());
1029 }
1030
1031 #[test]
1032 fn archives_cannot_traverse_retain_links_or_substitute_another_library() {
1033 let library = object(TARGETS[0]);
1034 assert!(
1035 extract_library(&zip(&[("../escape.dll", &library)]), "zip", "escape.dll").is_err()
1036 );
1037 assert!(
1038 extract_library(&zip(&[("C:\\escape.dll", &library)]), "zip", "escape.dll").is_err()
1039 );
1040 assert!(extract_library(&zip(&[("other.dll", &library)]), "zip", "declared.dll").is_err());
1041 let mut linked = zip::ZipWriter::new(Cursor::new(Vec::new()));
1042 linked
1043 .add_symlink(
1044 "declared.dll",
1045 "outside.dll",
1046 zip::write::SimpleFileOptions::default(),
1047 )
1048 .unwrap();
1049 assert!(extract_library(
1050 &linked.finish().unwrap().into_inner(),
1051 "zip",
1052 "declared.dll"
1053 )
1054 .is_err());
1055 let mut tar = tar::Builder::new(flate2::write::GzEncoder::new(
1056 Vec::new(),
1057 flate2::Compression::default(),
1058 ));
1059 let mut header = tar::Header::new_gnu();
1060 header.set_entry_type(tar::EntryType::Link);
1061 header.set_size(0);
1062 header.set_mode(0o644);
1063 header.set_cksum();
1064 tar.append_link(&mut header, "declared.so", "outside.so")
1065 .unwrap();
1066 assert!(extract_library(
1067 &tar.into_inner().unwrap().finish().unwrap(),
1068 "tar.gz",
1069 "declared.so"
1070 )
1071 .is_err());
1072 }
1073
1074 #[test]
1075 fn object_headers_cannot_cross_platform_or_architecture() {
1076 for target in TARGETS {
1077 assert!(validate_library_target(&object(target), target).is_ok());
1078 for other in TARGETS {
1079 if other != target {
1080 assert!(validate_library_target(&object(other), target).is_err());
1081 }
1082 }
1083 }
1084 assert!(validate_library_target(&[0u8; 8], TARGETS[0]).is_err());
1085 let mut damaged = object(TARGETS[0]);
1086 damaged[0x3c..0x40].copy_from_slice(&u32::MAX.to_le_bytes());
1087 assert!(validate_library_target(&damaged, TARGETS[0]).is_err());
1088 }
1089
1090 #[test]
1091 fn exact_versions_ignore_yanked_and_prerelease_latest() {
1092 let versions = [
1093 RegistryVersion {
1094 num: "1.0.0".into(),
1095 yanked: false,
1096 },
1097 RegistryVersion {
1098 num: "9.0.0".into(),
1099 yanked: true,
1100 },
1101 RegistryVersion {
1102 num: "2.0.0-beta.1".into(),
1103 yanked: false,
1104 },
1105 ];
1106 assert_eq!(select_version(&versions, None).unwrap(), "1.0.0");
1107 assert_eq!(
1108 select_version(&versions, Some("2.0.0-beta.1")).unwrap(),
1109 "2.0.0-beta.1"
1110 );
1111 assert!(select_version(&versions, Some("9.0.0")).is_err());
1112 assert!(select_version(&versions, Some("^1")).is_err());
1113 }
1114
1115 #[test]
1116 fn github_case_and_encoded_semver_build_metadata_preserve_release_identity() {
1117 let (mut package, mut value, _) = fixture(TARGETS[0]);
1118 package.version = "1.2.3+build.1".into();
1119 value["version"] = serde_json::json!(package.version);
1120 value["tag"] = serde_json::json!("independent-plugin-v1.2.3+build.1");
1121 value["repository"] = serde_json::json!("https://github.com/Publisher/MonoRepo");
1122 value["targets"][TARGETS[0]]["url"] = serde_json::json!("https://github.com/Publisher/MonoRepo/releases/download/independent-plugin-v1.2.3%2Bbuild.1/package.zip");
1123 let manifest = serde_json::from_value(value).unwrap();
1124 assert!(validate_manifest(&manifest, &package, TARGETS[0]).is_ok());
1125 assert_eq!(
1126 canonical_repository("Publisher/MonoRepo").unwrap(),
1127 package.repository
1128 );
1129 }
1130
1131 #[test]
1132 fn repository_urls_are_explicit_and_release_tags_use_package_versions() {
1133 assert_eq!(
1134 canonical_repository("git+https://github.com/publisher/monorepo.git").unwrap(),
1135 "https://github.com/publisher/monorepo"
1136 );
1137 assert_eq!(
1138 canonical_repository("https://github.com/publisher/monorepo/tree/main/packages/plugin")
1139 .unwrap(),
1140 "https://github.com/publisher/monorepo"
1141 );
1142 for invalid in [
1143 "https://gitlab.com/x/y",
1144 "https://user@github.com/x/y",
1145 "https://github.com/x",
1146 "https://github.com/x/y/releases/latest",
1147 "https://github.com:8443/x/y",
1148 ] {
1149 assert!(canonical_repository(invalid).is_err());
1150 }
1151 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");
1152 assert!(release_url(
1153 "https://github.com/publisher/monorepo",
1154 "plugin-two",
1155 "4.5.6",
1156 "rpi-extension.json"
1157 )
1158 .unwrap()
1159 .contains("plugin-two-v4.5.6"));
1160 }
1161
1162 fn one_response(body: Vec<u8>) -> (String, std::thread::JoinHandle<String>) {
1163 response_with_status("200 OK", body)
1164 }
1165
1166 fn response_with_status(
1167 status: &'static str,
1168 body: Vec<u8>,
1169 ) -> (String, std::thread::JoinHandle<String>) {
1170 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1171 let url = format!("http://{}", listener.local_addr().unwrap());
1172 let thread = std::thread::spawn(move || {
1173 let (mut socket, _) = listener.accept().unwrap();
1174 socket
1175 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
1176 .unwrap();
1177 let mut request = Vec::new();
1178 let mut byte = [0];
1179 while !request.ends_with(b"\r\n\r\n") {
1180 socket.read_exact(&mut byte).unwrap();
1181 request.push(byte[0]);
1182 }
1183 write!(
1184 socket,
1185 "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1186 body.len()
1187 )
1188 .unwrap();
1189 socket.write_all(&body).unwrap();
1190 String::from_utf8(request).unwrap()
1191 });
1192 (url, thread)
1193 }
1194
1195 #[tokio::test]
1196 async fn downloaded_manifest_and_archive_install_into_an_isolated_profile() {
1197 let target = host_target().unwrap();
1198 let (package, value, archive) = fixture(target);
1199 let raw = serde_json::to_vec(&value).unwrap();
1200 let client = reqwest::Client::builder().no_proxy().build().unwrap();
1201 let (url, server) = one_response(raw.clone());
1202 let response = client.get(url).send().await.unwrap();
1203 let (manifest, received) = read_manifest_response(response, &package).await.unwrap();
1204 server.join().unwrap();
1205 assert_eq!(received, raw);
1206 let distribution = validate_manifest(&manifest, &package, target).unwrap();
1207 let (url, server) = one_response(archive);
1208 let response = client.get(url).send().await.unwrap();
1209 let downloaded = body_limited(response, distribution.size).await.unwrap();
1210 server.join().unwrap();
1211 let profile = tempfile::tempdir().unwrap();
1212 let paths = install_archive_at(
1213 &package,
1214 &manifest,
1215 &received,
1216 &downloaded,
1217 target,
1218 profile.path(),
1219 false,
1220 )
1221 .unwrap();
1222 assert_eq!(std::fs::read(&paths[0]).unwrap(), object(target));
1223 assert!(profile.path().join("native-packages.json").is_file());
1224 }
1225
1226 #[tokio::test]
1227 async fn missing_invalid_and_oversized_manifests_fail_before_installation() {
1228 let (package, _, _) = fixture(TARGETS[0]);
1229 let client = reqwest::Client::builder().no_proxy().build().unwrap();
1230 for (status, body, expected) in [
1231 (
1232 "404 Not Found",
1233 b"missing".to_vec(),
1234 "rpi-extension.json is missing",
1235 ),
1236 ("403 Forbidden", b"denied".to_vec(), "403 Forbidden"),
1237 ("200 OK", b"not json".to_vec(), "invalid rpi-extension.json"),
1238 (
1239 "200 OK",
1240 vec![b' '; MAX_MANIFEST as usize + 1],
1241 "size limit",
1242 ),
1243 ] {
1244 let (url, server) = response_with_status(status, body);
1245 let response = client.get(url).send().await.unwrap();
1246 let error = read_manifest_response(response, &package)
1247 .await
1248 .err()
1249 .unwrap();
1250 assert!(error.contains(expected), "{error}");
1251 server.join().unwrap();
1252 }
1253 }
1254
1255 #[tokio::test]
1256 async fn explicit_repository_and_version_do_not_require_a_registry_record() {
1257 let client = reqwest::Client::new();
1258 let resolved = resolve_package(
1259 &client,
1260 "http://127.0.0.1:1",
1261 "private-extension",
1262 Some("2.3.4"),
1263 Some("https://github.com/custom-owner/custom-repo.git"),
1264 )
1265 .await
1266 .unwrap();
1267 assert_eq!(
1268 resolved.repository,
1269 "https://github.com/custom-owner/custom-repo"
1270 );
1271 assert_eq!(resolved.name, "private-extension");
1272 assert_eq!(resolved.version, "2.3.4");
1273 assert!(release_url(
1274 &resolved.repository,
1275 &resolved.name,
1276 &resolved.version,
1277 "rpi-extension.json"
1278 )
1279 .unwrap()
1280 .contains("custom-repo/releases/download/private-extension-v2.3.4/"));
1281 }
1282
1283 #[tokio::test]
1284 async fn custom_repository_versions_are_scoped_to_the_package() {
1285 let refs = serde_json::to_vec(&serde_json::json!([
1286 {"ref":"refs/tags/independent-plugin-v1.2.3"},
1287 {"ref":"refs/tags/independent-plugin-v1.4.0"},
1288 {"ref":"refs/tags/other-plugin-v99.0.0"},
1289 {"ref":"refs/tags/independent-plugin-v2.0.0-beta.1"}
1290 ]))
1291 .unwrap();
1292 let (api, server) = one_response(refs);
1293 let client = reqwest::Client::builder().no_proxy().build().unwrap();
1294 assert_eq!(
1295 repository_version(
1296 &client,
1297 &api,
1298 "independent-plugin",
1299 "https://github.com/publisher/monorepo"
1300 )
1301 .await
1302 .unwrap(),
1303 "1.4.0"
1304 );
1305 assert!(server.join().unwrap().starts_with(
1306 "GET /repos/publisher/monorepo/git/matching-refs/tags/independent-plugin-v "
1307 ));
1308 }
1309
1310 #[tokio::test]
1311 async fn registry_discovery_uses_repository_metadata_and_downloads_are_bounded() {
1312 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();
1313 let (url, server) = one_response(payload);
1314 let client = reqwest::Client::builder().no_proxy().build().unwrap();
1315 let package = resolve_package(&client, &url, "independent-plugin", None, None)
1316 .await
1317 .unwrap();
1318 assert_eq!(package.repository, "https://github.com/publisher/monorepo");
1319 assert_eq!(package.version, "1.2.3");
1320 assert!(server
1321 .join()
1322 .unwrap()
1323 .starts_with("GET /independent-plugin "));
1324 let (url, server) = one_response(vec![0u8; 32]);
1325 let response = client.get(url).send().await.unwrap();
1326 assert!(body_limited(response, 8).await.is_err());
1327 server.join().unwrap();
1328 }
1329}