1use std::collections::HashSet;
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15
16use serde::{Deserialize, Serialize};
17
18use crate::prebuilt_extensions::{asset_paths_conflict, validate_asset_path};
19
20const INSTALLER_MANIFEST: &str = "rpi-extension-installer";
21const NATIVE_PACKAGES_FILE: &str = "native-packages.json";
22const NATIVE_PACKAGES_LOCK_FILE: &str = "native-packages.lock";
23const CDYLIB_EXTENSION_HINT: &str = "hint: the crate must declare `crate-type = [\"cdylib\"]` and export `rpi_plugin_register` (the unified ABI; legacy `rpi_plugin_register_v2` and `rpi_plugin_register_v3` remain supported for migration)";
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub struct InstalledNativePackage {
33 pub name: String,
34 pub version: String,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub source: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub prebuilt: Option<crate::prebuilt_extensions::PrebuiltRecord>,
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
44 pub artifacts: Vec<String>,
45}
46
47impl InstalledNativePackage {
48 pub(crate) fn supports_registry_update(&self) -> bool {
51 self.source.is_none() || self.prebuilt.is_some()
52 }
53}
54
55pub(crate) fn should_install_version(
59 name: &str,
60 current: Option<&str>,
61 requested: &str,
62 force: bool,
63) -> Result<bool, String> {
64 let Some(current) = current.filter(|_| !force) else {
65 return Ok(true);
66 };
67 let parse = |version: &str| {
68 semver::Version::parse(version).map_err(|_| {
69 format!("cannot compare version `{version}` for `{name}`; use --force to replace it")
70 })
71 };
72 match parse(requested)?.cmp_precedence(&parse(current)?) {
73 std::cmp::Ordering::Greater => Ok(true),
74 std::cmp::Ordering::Equal => Ok(false),
75 std::cmp::Ordering::Less => Err(format!(
76 "{name} {current} is installed; use --force to downgrade to {requested}"
77 )),
78 }
79}
80
81pub(crate) fn print_already_installed(name: &str, version: &str) {
82 println!("{name} {version} is already installed; use --force to reinstall it");
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum NativePackageRegistry {
92 Missing,
94 Valid(Vec<InstalledNativePackage>),
96}
97
98impl NativePackageRegistry {
99 fn into_records(self) -> Vec<InstalledNativePackage> {
100 match self {
101 Self::Missing => Vec::new(),
102 Self::Valid(records) => records,
103 }
104 }
105}
106
107struct NativePackageMutationLock {
108 file: Option<std::fs::File>,
109 _resource_lease: crate::resource_management::Lease,
110}
111
112impl NativePackageMutationLock {
113 fn acquire(metadata_path: &Path) -> Result<Self, String> {
114 let parent = metadata_path
115 .parent()
116 .ok_or_else(|| "native package metadata has no parent".to_string())?;
117 let resource_lease = crate::resource_management::shared_lease(parent)?;
118 Self::acquire_with_lease(metadata_path, resource_lease)
119 }
120
121 fn acquire_exclusive(metadata_path: &Path) -> Result<Self, String> {
122 let parent = metadata_path
123 .parent()
124 .ok_or_else(|| "native package metadata has no parent".to_string())?;
125 let resource_lease = crate::resource_management::mutation_lease(parent)?;
126 Self::acquire_with_lease(metadata_path, resource_lease)
127 }
128
129 fn acquire_with_lease(
130 metadata_path: &Path,
131 resource_lease: crate::resource_management::Lease,
132 ) -> Result<Self, String> {
133 let parent = metadata_path
134 .parent()
135 .ok_or_else(|| "native package metadata has no parent".to_string())?;
136 std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
137 let lock_path = parent.join(NATIVE_PACKAGES_LOCK_FILE);
138 let file = std::fs::OpenOptions::new()
139 .create(true)
140 .read(true)
141 .write(true)
142 .open(&lock_path)
143 .map_err(|error| {
144 format!(
145 "could not open native package lock {}: {error}",
146 lock_path.display()
147 )
148 })?;
149 fs2::FileExt::try_lock_exclusive(&file).map_err(|error| {
150 format!("could not lock native package state (another install may be running): {error}")
151 })?;
152 Ok(Self {
153 file: Some(file),
154 _resource_lease: resource_lease,
155 })
156 }
157}
158
159impl Drop for NativePackageMutationLock {
160 fn drop(&mut self) {
161 if let Some(file) = self.file.take() {
162 let _ = fs2::FileExt::unlock(&file);
163 }
164 }
165}
166
167#[derive(Debug, Clone)]
168struct InstallOptions {
169 package: String,
170 version: Option<String>,
171 path: Option<PathBuf>,
172 locked: bool,
173 force: bool,
174 build: bool,
175 repository: Option<String>,
176 cdn_base: Option<reqwest::Url>,
177}
178
179pub async fn run_async(args: &[String]) -> i32 {
182 if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") {
183 print_help();
184 return 0;
185 }
186 let options = match parse_args(args) {
187 Ok(options) => options,
188 Err(error) => {
189 eprintln!("error: {error}");
190 print_help();
191 return 2;
192 }
193 };
194 if let Err(error) = installed_native_packages_strict() {
195 eprintln!("error: cannot safely install while native package metadata is invalid: {error}");
196 return 1;
197 }
198 if options.build || options.path.is_some() {
199 let args = args.to_vec();
200 return match tokio::task::spawn_blocking(move || run(&args)).await {
201 Ok(code) => code,
202 Err(error) => {
203 eprintln!("error: extension build failed: {error}");
204 1
205 }
206 };
207 }
208 if crate::args::offline_env_enabled() {
209 eprintln!("error: prebuilt installation requires network access; use --build for an explicit Cargo build");
210 return 1;
211 }
212 match crate::prebuilt_extensions::install(
213 &options.package,
214 options.version.as_deref(),
215 options.repository.as_deref(),
216 options.force,
217 options.cdn_base.as_ref(),
218 )
219 .await
220 {
221 Ok(()) => 0,
222 Err(error) => {
223 eprintln!("error: {error}");
224 1
225 }
226 }
227}
228
229#[derive(Debug, Deserialize)]
230struct CargoMetadata {
231 packages: Vec<CargoPackage>,
232}
233
234#[derive(Debug, Deserialize)]
235struct CargoPackage {
236 name: String,
237 version: String,
238 targets: Vec<CargoTarget>,
239}
240
241#[derive(Debug, Deserialize)]
242struct CargoTarget {
243 name: String,
244 crate_types: Vec<String>,
245}
246
247pub fn run(args: &[String]) -> i32 {
251 if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") {
252 print_help();
253 return 0;
254 }
255 let options = match parse_args(args) {
256 Ok(options) => options,
257 Err(message) => {
258 eprintln!("error: {message}");
259 print_help();
260 return 2;
261 }
262 };
263
264 let records = match installed_native_packages_strict() {
268 Ok(records) => records,
269 Err(error) => {
270 eprintln!(
271 "error: cannot safely install while native package metadata is invalid: {error}"
272 );
273 return 1;
274 }
275 };
276 if !options.build && options.path.is_none() {
277 eprintln!(
278 "error: prebuilt installs use the async dispatcher; Cargo compilation requires --build"
279 );
280 return 2;
281 }
282
283 let temp = match tempfile::tempdir() {
284 Ok(temp) => temp,
285 Err(error) => {
286 eprintln!("error: could not create a temporary Cargo workspace: {error}");
287 return 1;
288 }
289 };
290 let manifest = temp.path().join("Cargo.toml");
291 if let Err(error) = write_manifest(&manifest, &options) {
292 eprintln!("error: could not prepare Cargo workspace: {error}");
293 return 1;
294 }
295
296 if let Err(error) = cargo_command("fetch", &manifest, &options, false) {
297 eprintln!("error: could not resolve `{}`: {error}", options.package);
298 return 1;
299 }
300
301 let metadata = match cargo_metadata(&manifest, &options) {
302 Ok(metadata) => metadata,
303 Err(error) => {
304 eprintln!("error: could not inspect `{}`: {error}", options.package);
305 return 1;
306 }
307 };
308 let package = match metadata
309 .packages
310 .iter()
311 .find(|package| package.name == options.package)
312 {
313 Some(package) => package,
314 None => {
315 eprintln!(
316 "error: Cargo did not resolve a package named `{}`",
317 options.package
318 );
319 return 1;
320 }
321 };
322 match should_install_version(
323 &package.name,
324 records
325 .iter()
326 .find(|p| p.name == package.name)
327 .map(|p| p.version.as_str()),
328 &package.version,
329 options.force,
330 ) {
331 Ok(true) => {}
332 Ok(false) => {
333 print_already_installed(&package.name, &package.version);
334 return 0;
335 }
336 Err(error) => {
337 eprintln!("error: {error}");
338 return 1;
339 }
340 }
341 let cdylib_targets: Vec<&CargoTarget> = package
342 .targets
343 .iter()
344 .filter(|target| target.crate_types.iter().any(|kind| kind == "cdylib"))
345 .collect();
346 if cdylib_targets.is_empty() {
347 eprintln!(
348 "error: `{}` is not an rpi extension crate; it has no `cdylib` target",
349 options.package
350 );
351 eprintln!("{CDYLIB_EXTENSION_HINT}");
352 return 1;
353 }
354
355 if let Err(error) = cargo_command("build", &manifest, &options, true) {
356 eprintln!("error: failed to build `{}`: {error}", options.package);
357 return 1;
358 }
359
360 let artifact_dir = temp.path().join("target").join("release");
361 let artifacts = match find_artifacts(&artifact_dir, &cdylib_targets) {
362 Ok(artifacts) => artifacts,
363 Err(error) => {
364 eprintln!("error: {error}");
365 return 1;
366 }
367 };
368
369 let agent_dir = match crate::config::agent_dir() {
370 Ok(dir) => dir,
371 Err(error) => {
372 eprintln!("error: could not resolve the rpi config directory: {error}");
373 return 1;
374 }
375 };
376 let destination = agent_dir.join("extensions");
377 if let Err(error) = std::fs::create_dir_all(&destination) {
378 eprintln!(
379 "error: could not create extension directory {}: {error}",
380 destination.display()
381 );
382 return 1;
383 }
384
385 let version = metadata
386 .packages
387 .iter()
388 .find(|candidate| candidate.name == options.package)
389 .map(|candidate| candidate.version.clone())
390 .unwrap_or_else(|| "0.0.0".to_string());
391 let record = InstalledNativePackage {
392 name: options.package.clone(),
393 version,
394 source: options
395 .path
396 .as_ref()
397 .map(|path| path.to_string_lossy().into_owned()),
398 prebuilt: None,
399 artifacts: Vec::new(),
400 };
401 let installed = match install_native_package_at(
402 &destination,
403 &agent_dir.join(NATIVE_PACKAGES_FILE),
404 record,
405 &artifacts,
406 options.force,
407 ) {
408 Ok(installed) => installed,
409 Err(error) => {
410 eprintln!("error: could not install `{}`: {error}", options.package);
411 return 1;
412 }
413 };
414 for target in installed {
415 println!("installed {}", target.display());
416 }
417 gc_retired_native_artifacts(&agent_dir);
418 println!("rpi will load this extension on the next start.");
419 0
420}
421
422pub fn uninstall(args: &[String]) -> i32 {
428 if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") {
429 print_uninstall_help();
430 return 0;
431 }
432 let name = match parse_uninstall_name(args) {
433 Ok(name) => name,
434 Err(error) => {
435 eprintln!("error: {error}");
436 print_uninstall_help();
437 return 2;
438 }
439 };
440 let agent = match crate::config::agent_dir() {
441 Ok(path) => path,
442 Err(error) => {
443 eprintln!("error: could not resolve the rpi config directory: {error}");
444 return 1;
445 }
446 };
447 let extension_dir = agent.join("extensions");
448 let metadata_path = agent.join(NATIVE_PACKAGES_FILE);
449 let _lock = match NativePackageMutationLock::acquire_exclusive(&metadata_path) {
450 Ok(lock) => lock,
451 Err(error) => {
452 eprintln!("error: cannot safely uninstall: {error}");
453 return 1;
454 }
455 };
456 let records = match read_native_package_registry_at(&metadata_path) {
457 Ok(registry) => registry.into_records(),
458 Err(error) => {
459 eprintln!(
460 "error: cannot safely uninstall while native package metadata is invalid: {error}"
461 );
462 return 1;
463 }
464 };
465 let mut asset_targets = Vec::new();
466 for record in records.iter().filter(|record| record.name == name) {
467 for asset in recorded_asset_paths(record) {
468 match checked_asset_target(&extension_dir, asset) {
469 Ok(path) => asset_targets.push(path),
470 Err(error) => {
471 eprintln!("error: cannot safely uninstall: {error}");
472 return 1;
473 }
474 }
475 }
476 }
477 let had_record = records.iter().any(|record| record.name == name);
478 let wanted = normalize_name(&name);
479 let artifact_names: std::collections::HashSet<String> = records
480 .iter()
481 .filter(|record| record.name == name)
482 .flat_map(|record| record.artifacts.iter().cloned())
483 .collect();
484 let use_legacy_name_fallback = artifact_names.is_empty();
485 let mut artifact_targets = Vec::new();
486 if let Ok(entries) = std::fs::read_dir(&extension_dir) {
487 for entry in entries.flatten() {
488 let path = entry.path();
489 let file_name = path
490 .file_name()
491 .map(|name| name.to_string_lossy().into_owned())
492 .unwrap_or_default();
493 let is_artifact = artifact_names.contains(&file_name)
494 || is_versioned_artifact_for_package(&file_name, &wanted)
495 || (use_legacy_name_fallback
496 && path
497 .file_stem()
498 .and_then(|stem| stem.to_str())
499 .map(|stem| normalize_name(stem.trim_start_matches("lib")) == wanted)
500 .unwrap_or(false));
501 if is_artifact && is_dynamic_library(&path) {
502 artifact_targets.push(path);
503 }
504 }
505 }
506 if let Err(error) =
507 crate::resource_management::legacy_unowned_targets(&agent, &artifact_targets)
508 {
509 eprintln!("error: {error}");
510 return 1;
511 }
512 let mut remaining: Vec<_> = records
513 .into_iter()
514 .filter(|record| record.name != name)
515 .collect();
516 if had_record {
517 remaining.sort_by(|left, right| left.name.cmp(&right.name));
518 }
519 let mut replacements = Vec::new();
520 let mut removals = artifact_targets.clone();
521 removals.extend(asset_targets.iter().cloned());
522 if had_record {
523 if remaining.is_empty() {
524 removals.push(metadata_path.clone());
525 } else {
526 let registry = match serde_json::to_vec_pretty(&remaining) {
527 Ok(registry) => registry,
528 Err(error) => {
529 eprintln!("error: could not serialize native package metadata: {error}");
530 return 1;
531 }
532 };
533 let staged = match stage_native_bytes(®istry, &metadata_path) {
534 Ok(staged) => staged,
535 Err(error) => {
536 eprintln!("error: could not stage native package metadata: {error}");
537 return 1;
538 }
539 };
540 replacements.push(NativeReplacement {
541 staged,
542 target: metadata_path.clone(),
543 });
544 }
545 }
546 if let Err(error) = activate_native_transaction(&replacements, &removals, &mut |_, _| Ok(())) {
547 eprintln!("error: could not uninstall Rust extension {name}: {error}");
548 return 1;
549 }
550 prune_empty_dirs(&extension_dir, &asset_targets);
551 for path in artifact_targets.iter().chain(&asset_targets) {
552 println!("removed {}", path.display());
553 }
554 let removed = artifact_targets.len() + asset_targets.len();
555 if removed == 0 && !had_record {
556 println!("Rust extension is not installed: {name}");
557 return 0;
558 }
559 println!("uninstalled Rust extension {name}");
560 0
561}
562
563fn parse_uninstall_name(args: &[String]) -> Result<String, String> {
564 let mut name = None;
565 for arg in args {
566 match arg.as_str() {
567 "--help" | "-h" => return Err("use `rpi uninstall --help` for usage".into()),
568 value if value.starts_with('-') => {
569 return Err(format!("unknown uninstall option `{value}`"))
570 }
571 value => {
572 if name.replace(value.to_string()).is_some() {
573 return Err("uninstall accepts exactly one crate name".into());
574 }
575 }
576 }
577 }
578 let name = name.ok_or_else(|| "missing crate name".to_string())?;
579 if !valid_package_name(&name) {
580 return Err(format!("invalid Cargo package name `{name}`"));
581 }
582 Ok(name)
583}
584
585#[cfg(test)]
586fn write_native_packages_at(path: &Path, records: &[InstalledNativePackage]) -> Result<(), String> {
587 if records.is_empty() {
588 match std::fs::remove_file(path) {
589 Ok(()) => return Ok(()),
590 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
591 Err(error) => return Err(error.to_string()),
592 }
593 }
594 let parent = path
595 .parent()
596 .ok_or_else(|| "native package metadata has no parent".to_string())?;
597 std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
598 let data = serde_json::to_vec_pretty(records).map_err(|error| error.to_string())?;
599 std::fs::write(path, data).map_err(|error| error.to_string())
600}
601
602pub fn read_native_package_registry() -> Result<NativePackageRegistry, String> {
604 let path = crate::config::agent_dir()
605 .map_err(|error| error.to_string())?
606 .join(NATIVE_PACKAGES_FILE);
607 read_native_package_registry_at(&path)
608}
609
610fn read_native_package_registry_at(path: &Path) -> Result<NativePackageRegistry, String> {
611 let data = match std::fs::read(path) {
612 Ok(data) => data,
613 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
614 return Ok(NativePackageRegistry::Missing)
615 }
616 Err(error) => {
617 return Err(format!("could not read {}: {error}", path.display()));
618 }
619 };
620 serde_json::from_slice(&data)
621 .map(NativePackageRegistry::Valid)
622 .map_err(|error| format!("invalid JSON or schema in {}: {error}", path.display()))
623}
624
625pub fn installed_native_packages() -> Vec<InstalledNativePackage> {
631 installed_native_packages_strict().unwrap_or_default()
632}
633
634pub(crate) fn installed_native_packages_strict() -> Result<Vec<InstalledNativePackage>, String> {
638 read_native_package_registry().map(NativePackageRegistry::into_records)
639}
640
641pub fn active_global_native_package_artifacts() -> Vec<PathBuf> {
646 let Ok(agent_dir) = crate::config::agent_dir() else {
647 return Vec::new();
648 };
649 active_global_native_package_artifacts_at(&agent_dir).unwrap_or_default()
650}
651
652pub fn unregistered_global_native_package_artifacts() -> Vec<PathBuf> {
656 let Ok(agent_dir) = crate::config::agent_dir() else {
657 return Vec::new();
658 };
659 unregistered_global_native_package_artifacts_at(&agent_dir).unwrap_or_default()
660}
661
662pub(crate) fn active_global_native_package_artifacts_at(
663 agent_dir: &Path,
664) -> Result<Vec<PathBuf>, String> {
665 let destination = agent_dir.join("extensions");
666 let records =
667 read_native_package_registry_at(&agent_dir.join(NATIVE_PACKAGES_FILE))?.into_records();
668 let mut paths = Vec::new();
669 let mut seen = HashSet::new();
670 for record in records {
671 if record.artifacts.is_empty() {
672 let wanted = normalize_name(&record.name);
673 let Ok(entries) = std::fs::read_dir(&destination) else {
674 continue;
675 };
676 for entry in entries {
677 let entry = entry.map_err(|error| {
678 format!("could not inspect native extension directory: {error}")
679 })?;
680 let path = entry.path();
681 let matches = path
682 .file_stem()
683 .and_then(|stem| stem.to_str())
684 .map(|stem| normalize_name(stem.trim_start_matches("lib")) == wanted)
685 .unwrap_or(false);
686 if matches && is_dynamic_library(&path) {
687 let identity = transaction_path_identity(&path);
688 if seen.insert(identity) {
689 paths.push(path);
690 }
691 }
692 }
693 } else {
694 for artifact in record.artifacts {
695 let path = checked_registered_artifact_path(&destination, &artifact)?;
696 if seen.insert(transaction_path_identity(&path)) {
697 paths.push(path);
698 }
699 }
700 }
701 }
702 paths.sort();
703 Ok(paths)
704}
705
706pub(crate) fn unregistered_global_native_package_artifacts_at(
707 agent_dir: &Path,
708) -> Result<Vec<PathBuf>, String> {
709 let destination = agent_dir.join("extensions");
710 let records =
711 read_native_package_registry_at(&agent_dir.join(NATIVE_PACKAGES_FILE))?.into_records();
712 let managed = records
713 .iter()
714 .flat_map(|record| record.artifacts.iter())
715 .map(|artifact| native_artifact_identity(artifact))
716 .collect::<HashSet<_>>();
717 let legacy_names = records
718 .iter()
719 .filter(|record| record.artifacts.is_empty())
720 .map(|record| normalize_name(&record.name))
721 .collect::<HashSet<_>>();
722 let mut paths = Vec::new();
723 let Ok(entries) = std::fs::read_dir(&destination) else {
724 return Ok(paths);
725 };
726 for entry in entries {
727 let path = entry
728 .map_err(|error| format!("could not inspect native extension directory: {error}"))?
729 .path();
730 let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
731 continue;
732 };
733 let legacy_managed = path
734 .file_stem()
735 .and_then(|stem| stem.to_str())
736 .map(|stem| legacy_names.contains(&normalize_name(stem.trim_start_matches("lib"))))
737 .unwrap_or(false);
738 if !is_dynamic_library(&path)
739 || managed.contains(&native_artifact_identity(name))
740 || is_versioned_artifact_name(name)
741 || legacy_managed
742 {
743 continue;
744 }
745 paths.push(path);
746 }
747 paths.sort();
748 Ok(paths)
749}
750
751#[cfg(test)]
752fn record_native_package_at(path: &Path, record: &InstalledNativePackage) -> Result<(), String> {
753 let mut records = read_native_package_registry_at(path)?.into_records();
754 if let Some(existing) = records.iter_mut().find(|item| item.name == record.name) {
755 *existing = record.clone();
756 } else {
757 records.push(record.clone());
758 }
759 records.sort_by(|left, right| left.name.cmp(&right.name));
760 write_native_packages_at(path, &records)
761}
762
763struct StagedNativeFile {
764 path: PathBuf,
765}
766
767impl Drop for StagedNativeFile {
768 fn drop(&mut self) {
769 let _ = std::fs::remove_file(&self.path);
770 }
771}
772
773struct NativeReplacement {
774 staged: StagedNativeFile,
775 target: PathBuf,
776}
777
778struct NativeBackup {
779 target: PathBuf,
780 backup: PathBuf,
781}
782
783pub(crate) struct NativeAssetFile {
786 pub path: String,
787 pub bytes: Vec<u8>,
788}
789
790pub(crate) fn install_native_package_at(
791 destination: &Path,
792 metadata_path: &Path,
793 record: InstalledNativePackage,
794 artifacts: &[PathBuf],
795 force: bool,
796) -> Result<Vec<PathBuf>, String> {
797 install_native_package_with_assets_at(destination, metadata_path, record, artifacts, &[], force)
798}
799
800pub(crate) fn install_native_package_with_assets_at(
803 destination: &Path,
804 metadata_path: &Path,
805 record: InstalledNativePackage,
806 artifacts: &[PathBuf],
807 assets: &[NativeAssetFile],
808 force: bool,
809) -> Result<Vec<PathBuf>, String> {
810 install_native_package_at_with_hook(
811 destination,
812 metadata_path,
813 record,
814 artifacts,
815 assets,
816 force,
817 |_, _| Ok(()),
818 )
819}
820
821fn install_native_package_at_with_hook(
822 destination: &Path,
823 metadata_path: &Path,
824 mut record: InstalledNativePackage,
825 artifacts: &[PathBuf],
826 assets: &[NativeAssetFile],
827 force: bool,
828 mut before_activate: impl FnMut(usize, &Path) -> Result<(), String>,
829) -> Result<Vec<PathBuf>, String> {
830 std::fs::create_dir_all(destination).map_err(|error| {
831 format!(
832 "could not create extension directory {}: {error}",
833 destination.display()
834 )
835 })?;
836 let metadata_parent = metadata_path
837 .parent()
838 .ok_or_else(|| "native package metadata has no parent".to_string())?;
839 std::fs::create_dir_all(metadata_parent).map_err(|error| error.to_string())?;
840 let _lock = NativePackageMutationLock::acquire(metadata_path)?;
841 let mut records = read_native_package_registry_at(metadata_path)?.into_records();
842 let previous = records
843 .iter()
844 .find(|candidate| candidate.name == record.name)
845 .cloned();
846
847 if !should_install_version(
848 &record.name,
849 previous.as_ref().map(|p| p.version.as_str()),
850 &record.version,
851 force,
852 )? {
853 print_already_installed(&record.name, &record.version);
854 return Ok(Vec::new());
855 }
856
857 let mut source_artifacts = Vec::with_capacity(artifacts.len());
858 let mut source_keys = HashSet::new();
859 for artifact in artifacts {
860 let metadata = std::fs::symlink_metadata(artifact).map_err(|error| {
861 format!(
862 "could not inspect built artifact {}: {error}",
863 artifact.display()
864 )
865 })?;
866 if !metadata.is_file() || metadata.file_type().is_symlink() {
867 return Err(format!(
868 "built artifact is not a regular file: {}",
869 artifact.display()
870 ));
871 }
872 let name = artifact_file_name(artifact)?;
873 if !source_keys.insert(native_artifact_identity(&name)) {
874 return Err(format!("duplicate built artifact name `{name}`"));
875 }
876 source_artifacts.push((name, artifact.clone()));
877 }
878 if source_artifacts.is_empty() {
879 return Err("Cargo produced no installable cdylib artifacts".to_string());
880 }
881
882 let mut new_artifacts = Vec::with_capacity(source_artifacts.len());
887 let mut new_keys = HashSet::new();
888 for (name, source) in source_artifacts {
889 let target_name = if previous.is_some() || (force && destination.join(&name).exists()) {
890 versioned_artifact_name(&name, &record.version, destination)?
891 } else {
892 name
893 };
894 if !new_keys.insert(native_artifact_identity(&target_name)) {
895 return Err(format!("duplicate versioned artifact name `{target_name}`"));
896 }
897 new_artifacts.push((target_name, source));
898 }
899
900 for other in records
901 .iter()
902 .filter(|candidate| candidate.name != record.name)
903 {
904 for owned in &other.artifacts {
905 checked_registered_artifact_path(destination, owned)?;
906 if new_keys.contains(&native_artifact_identity(owned)) {
907 return Err(format!(
908 "artifact `{owned}` is already owned by installed package `{}`",
909 other.name
910 ));
911 }
912 }
913 }
914
915 validate_new_assets(&record, assets, &new_artifacts)?;
917 for other in records
918 .iter()
919 .filter(|candidate| candidate.name != record.name)
920 {
921 for owned in recorded_asset_paths(other) {
922 let clash = new_artifacts
923 .iter()
924 .map(|(name, _)| name.as_str())
925 .chain(assets.iter().map(|asset| asset.path.as_str()))
926 .find(|new| asset_paths_conflict(new, owned));
927 if let Some(new) = clash {
928 return Err(format!(
929 "`{new}` conflicts with asset `{owned}` owned by installed package `{}`",
930 other.name
931 ));
932 }
933 }
934 }
935
936 let installed_targets = new_artifacts
937 .iter()
938 .map(|(name, _)| destination.join(name))
939 .collect::<Vec<_>>();
940 crate::resource_management::legacy_unowned_targets(metadata_parent, &installed_targets)?;
941 for target in &installed_targets {
942 match std::fs::symlink_metadata(target) {
943 Ok(_) => {
944 return Err(format!(
945 "versioned extension target {} already exists",
946 target.display()
947 ))
948 }
949 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
950 Err(error) => {
951 return Err(format!(
952 "could not inspect extension target {}: {error}",
953 target.display()
954 ))
955 }
956 }
957 }
958
959 let previous_assets: Vec<String> = previous
960 .as_ref()
961 .map(|package| {
962 recorded_asset_paths(package)
963 .into_iter()
964 .map(str::to_owned)
965 .collect()
966 })
967 .unwrap_or_default();
968 let mut asset_targets = Vec::with_capacity(assets.len());
969 for asset in assets {
970 let target = checked_asset_target(destination, &asset.path)?;
971 let owned = previous_assets.iter().any(|old| {
975 native_artifact_identity(old) == native_artifact_identity(&asset.path)
976 || (old.eq_ignore_ascii_case(&asset.path)
977 && same_file(&destination.join(old), &target))
978 });
979 if !force && !owned {
980 match std::fs::symlink_metadata(&target) {
981 Ok(_) => {
982 return Err(format!(
983 "extension {} already exists; use --force to replace it",
984 target.display()
985 ))
986 }
987 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
988 Err(error) => {
989 return Err(format!(
990 "could not inspect extension target {}: {error}",
991 target.display()
992 ))
993 }
994 }
995 }
996 asset_targets.push(target);
997 }
998 let mut stale_asset_targets = Vec::new();
999 for old in &previous_assets {
1000 if !assets
1003 .iter()
1004 .any(|asset| native_artifact_identity(&asset.path) == native_artifact_identity(old))
1005 {
1006 stale_asset_targets.push(checked_asset_target(destination, old)?);
1007 }
1008 }
1009
1010 record.artifacts = new_artifacts.iter().map(|(name, _)| name.clone()).collect();
1011 if let Some(existing) = records
1012 .iter_mut()
1013 .find(|candidate| candidate.name == record.name)
1014 {
1015 *existing = record;
1016 } else {
1017 records.push(record);
1018 }
1019 records.sort_by(|left, right| left.name.cmp(&right.name));
1020
1021 let mut replacements = Vec::with_capacity(new_artifacts.len() + assets.len() + 1);
1022 for ((_, source), target) in new_artifacts.iter().zip(&installed_targets) {
1023 replacements.push(NativeReplacement {
1024 staged: stage_native_artifact(source, target)?,
1025 target: target.clone(),
1026 });
1027 }
1028 let stage = if assets.is_empty() {
1032 None
1033 } else {
1034 Some(StagedDirectory::create(destination)?)
1035 };
1036 if let Some(stage) = &stage {
1037 for (asset, target) in assets.iter().zip(&asset_targets) {
1038 let staged = stage.path.join(&asset.path);
1039 if let Some(parent) = staged.parent() {
1040 std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
1041 }
1042 write_staged_asset(&asset.bytes, &staged)?;
1043 replacements.push(NativeReplacement {
1044 staged: StagedNativeFile { path: staged },
1045 target: target.clone(),
1046 });
1047 }
1048 }
1049 let registry = serde_json::to_vec_pretty(&records).map_err(|error| error.to_string())?;
1050 replacements.push(NativeReplacement {
1051 staged: stage_native_bytes(®istry, metadata_path)?,
1052 target: metadata_path.to_path_buf(),
1053 });
1054 let mut created_dirs = Vec::new();
1056 for target in &asset_targets {
1057 if let Some(parent) = target.parent() {
1058 created_dirs.extend(
1059 parent
1060 .ancestors()
1061 .take_while(|dir| *dir != destination && !dir.exists())
1062 .map(Path::to_path_buf),
1063 );
1064 if let Err(error) = std::fs::create_dir_all(parent) {
1065 remove_created_dirs(&mut created_dirs);
1066 return Err(format!(
1067 "could not create extension directory {}: {error}",
1068 parent.display()
1069 ));
1070 }
1071 }
1072 }
1073
1074 let mut removals = Vec::new();
1077 removals.extend(stale_asset_targets.iter().cloned());
1078 if let Err(error) = activate_native_transaction(&replacements, &removals, &mut before_activate)
1079 {
1080 remove_created_dirs(&mut created_dirs);
1081 return Err(error);
1082 }
1083 prune_empty_dirs(destination, &stale_asset_targets);
1084 Ok(installed_targets.into_iter().chain(asset_targets).collect())
1085}
1086
1087fn validate_new_assets(
1090 record: &InstalledNativePackage,
1091 assets: &[NativeAssetFile],
1092 artifacts: &[(String, PathBuf)],
1093) -> Result<(), String> {
1094 let declared: Vec<&str> = assets.iter().map(|asset| asset.path.as_str()).collect();
1095 if recorded_asset_paths(record) != declared {
1096 return Err("internal error: sidecar ledger does not match the staged assets".into());
1097 }
1098 for (index, asset) in assets.iter().enumerate() {
1099 validate_asset_path(&asset.path)?;
1100 if assets[..index]
1101 .iter()
1102 .any(|other| asset_paths_conflict(&other.path, &asset.path))
1103 {
1104 return Err(format!(
1105 "duplicate or overlapping asset path `{}`",
1106 asset.path
1107 ));
1108 }
1109 if artifacts
1110 .iter()
1111 .any(|(name, _)| asset_paths_conflict(name, &asset.path))
1112 {
1113 return Err(format!(
1114 "asset `{}` collides with a library artifact",
1115 asset.path
1116 ));
1117 }
1118 }
1119 Ok(())
1120}
1121
1122fn recorded_asset_paths(package: &InstalledNativePackage) -> Vec<&str> {
1123 package
1124 .prebuilt
1125 .iter()
1126 .flat_map(|prebuilt| prebuilt.assets.iter().map(|asset| asset.path.as_str()))
1127 .collect()
1128}
1129
1130fn checked_asset_target(destination: &Path, relative: &str) -> Result<PathBuf, String> {
1133 validate_asset_path(relative)?;
1134 let parts: Vec<&str> = relative.split('/').collect();
1135 let mut current = destination.to_path_buf();
1136 for part in &parts[..parts.len() - 1] {
1137 current.push(part);
1138 match std::fs::symlink_metadata(¤t) {
1139 Ok(metadata) if metadata.is_dir() => {}
1140 Ok(_) => {
1141 return Err(format!(
1142 "asset parent {} is not a plain directory",
1143 current.display()
1144 ))
1145 }
1146 Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
1147 Err(error) => {
1148 return Err(format!(
1149 "could not inspect asset parent {}: {error}",
1150 current.display()
1151 ))
1152 }
1153 }
1154 }
1155 Ok(destination.join(relative))
1156}
1157
1158struct StagedDirectory {
1160 path: PathBuf,
1161}
1162
1163impl StagedDirectory {
1164 fn create(destination: &Path) -> Result<Self, String> {
1165 let path = destination.join(format!(
1166 ".rpi-assets-stage-{}",
1167 uuid::Uuid::new_v4().simple()
1168 ));
1169 std::fs::create_dir(&path).map_err(|error| {
1170 format!(
1171 "could not create asset staging directory {}: {error}",
1172 path.display()
1173 )
1174 })?;
1175 Ok(Self { path })
1176 }
1177}
1178
1179impl Drop for StagedDirectory {
1180 fn drop(&mut self) {
1181 let _ = std::fs::remove_dir_all(&self.path);
1182 }
1183}
1184
1185fn write_staged_asset(bytes: &[u8], path: &Path) -> Result<(), String> {
1186 let mut file = std::fs::OpenOptions::new()
1187 .write(true)
1188 .create_new(true)
1189 .open(path)
1190 .map_err(|error| format!("could not create staged asset {}: {error}", path.display()))?;
1191 file.write_all(bytes)
1192 .and_then(|()| file.sync_all())
1193 .map_err(|error| format!("could not write staged asset {}: {error}", path.display()))
1194}
1195
1196fn remove_created_dirs(dirs: &mut [PathBuf]) {
1199 dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
1200 for dir in dirs.iter() {
1201 let _ = std::fs::remove_dir(dir);
1202 }
1203}
1204
1205fn prune_empty_dirs(root: &Path, removed: &[PathBuf]) {
1207 for path in removed {
1208 let mut current = path.parent();
1209 while let Some(dir) = current {
1210 if dir == root || !dir.starts_with(root) || std::fs::remove_dir(dir).is_err() {
1211 break;
1212 }
1213 current = dir.parent();
1214 }
1215 }
1216}
1217
1218fn is_versioned_artifact_name(name: &str) -> bool {
1219 let Some(stem) = Path::new(name).file_stem().and_then(|value| value.to_str()) else {
1220 return false;
1221 };
1222 let Some((prefix, token)) = stem.rsplit_once('-') else {
1223 return false;
1224 };
1225 prefix.rsplit_once("-v").is_some()
1226 && token.len() == 32
1227 && token.bytes().all(|value| value.is_ascii_hexdigit())
1228}
1229
1230fn is_versioned_artifact_for_package(name: &str, normalized_package: &str) -> bool {
1231 let Some(stem) = Path::new(name).file_stem().and_then(|value| value.to_str()) else {
1232 return false;
1233 };
1234 let Some((prefix, _)) = stem.rsplit_once("-v") else {
1235 return false;
1236 };
1237 is_versioned_artifact_name(name)
1238 && normalize_name(prefix.trim_start_matches("lib")) == normalized_package
1239}
1240
1241fn gc_retired_native_artifacts(agent_dir: &Path) {
1246 let metadata_path = agent_dir.join(NATIVE_PACKAGES_FILE);
1247 let lease = match crate::resource_management::mutation_lease(agent_dir) {
1248 Ok(lease) => lease,
1249 Err(_) => return,
1250 };
1251 let _lock = match NativePackageMutationLock::acquire_with_lease(&metadata_path, lease) {
1252 Ok(lock) => lock,
1253 Err(_) => return,
1254 };
1255 let records = match read_native_package_registry_at(&metadata_path) {
1256 Ok(registry) => registry.into_records(),
1257 Err(_) => return,
1258 };
1259 let active = records
1260 .iter()
1261 .flat_map(|record| record.artifacts.iter())
1262 .map(|artifact| native_artifact_identity(artifact))
1263 .collect::<HashSet<_>>();
1264 let destination = agent_dir.join("extensions");
1265 let Ok(entries) = std::fs::read_dir(&destination) else {
1266 return;
1267 };
1268 for entry in entries.flatten() {
1269 let path = entry.path();
1270 let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
1271 continue;
1272 };
1273 if !is_dynamic_library(&path)
1274 || !is_versioned_artifact_name(name)
1275 || active.contains(&native_artifact_identity(name))
1276 {
1277 continue;
1278 }
1279 if crate::resource_management::legacy_unowned_targets(
1280 agent_dir,
1281 std::slice::from_ref(&path),
1282 )
1283 .is_err()
1284 {
1285 continue;
1286 }
1287 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
1288 continue;
1289 };
1290 if metadata.file_type().is_file() {
1291 let _ = std::fs::remove_file(path);
1292 }
1293 }
1294}
1295
1296pub(crate) fn versioned_artifact_name(
1297 original: &str,
1298 version: &str,
1299 destination: &Path,
1300) -> Result<String, String> {
1301 let path = Path::new(original);
1302 let stem = path
1303 .file_stem()
1304 .and_then(|value| value.to_str())
1305 .ok_or_else(|| format!("artifact has no UTF-8 file stem: {original}"))?;
1306 let extension = path
1307 .extension()
1308 .and_then(|value| value.to_str())
1309 .ok_or_else(|| format!("artifact has no dynamic-library extension: {original}"))?;
1310 let version = version
1311 .chars()
1312 .map(|value| {
1313 if value.is_ascii_alphanumeric() || matches!(value, '.' | '-' | '_') {
1314 value
1315 } else {
1316 '_'
1317 }
1318 })
1319 .collect::<String>();
1320 let version = if version.is_empty() {
1321 "unknown"
1322 } else {
1323 &version
1324 };
1325 for _ in 0..8 {
1326 let name = format!(
1327 "{stem}-v{version}-{}.{}",
1328 uuid::Uuid::new_v4().simple(),
1329 extension
1330 );
1331 let target = destination.join(&name);
1332 if matches!(std::fs::symlink_metadata(&target), Err(error) if error.kind() == std::io::ErrorKind::NotFound)
1333 {
1334 return Ok(name);
1335 }
1336 }
1337 Err(format!(
1338 "could not allocate a unique versioned artifact for {original}"
1339 ))
1340}
1341
1342fn artifact_file_name(path: &Path) -> Result<String, String> {
1343 let name = path
1344 .file_name()
1345 .and_then(|name| name.to_str())
1346 .ok_or_else(|| format!("artifact has no UTF-8 file name: {}", path.display()))?;
1347 checked_registered_artifact_path(Path::new("."), name)?;
1348 Ok(name.to_string())
1349}
1350
1351fn checked_registered_artifact_path(destination: &Path, name: &str) -> Result<PathBuf, String> {
1352 let relative = Path::new(name);
1353 let is_single_component = relative.components().count() == 1
1354 && relative.file_name().and_then(|value| value.to_str()) == Some(name);
1355 if !is_single_component || !is_dynamic_library(relative) {
1356 return Err(format!("unsafe native package artifact name `{name}`"));
1357 }
1358 Ok(destination.join(relative))
1359}
1360
1361fn native_artifact_identity(name: &str) -> String {
1362 if cfg!(windows) {
1363 name.to_ascii_lowercase()
1364 } else {
1365 name.to_string()
1366 }
1367}
1368
1369#[cfg(unix)]
1372fn same_file(left: &Path, right: &Path) -> bool {
1373 use std::os::unix::fs::MetadataExt;
1374 match (
1375 std::fs::symlink_metadata(left),
1376 std::fs::symlink_metadata(right),
1377 ) {
1378 (Ok(left), Ok(right)) => left.dev() == right.dev() && left.ino() == right.ino(),
1379 _ => false,
1380 }
1381}
1382
1383#[cfg(not(unix))]
1384fn same_file(_: &Path, _: &Path) -> bool {
1385 false
1386}
1387
1388fn transaction_path_identity(path: &Path) -> String {
1389 let value = path.to_string_lossy().into_owned();
1390 if cfg!(windows) {
1391 value.to_ascii_lowercase()
1392 } else {
1393 value
1394 }
1395}
1396
1397fn adjacent_transaction_path(target: &Path, kind: &str) -> Result<PathBuf, String> {
1398 let parent = target
1399 .parent()
1400 .ok_or_else(|| format!("transaction target has no parent: {}", target.display()))?;
1401 let leaf = target
1402 .file_name()
1403 .and_then(|name| name.to_str())
1404 .unwrap_or("artifact");
1405 for _ in 0..8 {
1406 let candidate = parent.join(format!(
1407 ".{leaf}.rpi-{kind}-{}",
1408 uuid::Uuid::new_v4().simple()
1409 ));
1410 match std::fs::symlink_metadata(&candidate) {
1411 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(candidate),
1412 Ok(_) => {}
1413 Err(error) => {
1414 return Err(format!(
1415 "could not inspect transaction path {}: {error}",
1416 candidate.display()
1417 ))
1418 }
1419 }
1420 }
1421 Err(format!(
1422 "could not allocate a transaction path beside {}",
1423 target.display()
1424 ))
1425}
1426
1427fn stage_native_artifact(source: &Path, target: &Path) -> Result<StagedNativeFile, String> {
1428 let stage = adjacent_transaction_path(target, "stage")?;
1429 let result = (|| {
1430 let source_metadata = std::fs::symlink_metadata(source).map_err(|error| {
1431 format!(
1432 "could not inspect built artifact {}: {error}",
1433 source.display()
1434 )
1435 })?;
1436 let mut input = std::fs::File::open(source).map_err(|error| {
1437 format!(
1438 "could not open built artifact {}: {error}",
1439 source.display()
1440 )
1441 })?;
1442 let mut output = std::fs::OpenOptions::new()
1443 .write(true)
1444 .create_new(true)
1445 .open(&stage)
1446 .map_err(|error| {
1447 format!(
1448 "could not create staged artifact {}: {error}",
1449 stage.display()
1450 )
1451 })?;
1452 std::io::copy(&mut input, &mut output)
1453 .and_then(|_| output.sync_all())
1454 .map_err(|error| {
1455 format!(
1456 "could not write staged artifact {} beside {}: {error}",
1457 stage.display(),
1458 target.display()
1459 )
1460 })?;
1461 drop(output);
1462 std::fs::set_permissions(&stage, source_metadata.permissions()).map_err(|error| {
1463 format!(
1464 "could not preserve permissions on staged artifact {}: {error}",
1465 stage.display()
1466 )
1467 })
1468 })();
1469 if let Err(error) = result {
1470 let _ = std::fs::remove_file(&stage);
1471 return Err(error);
1472 }
1473 Ok(StagedNativeFile { path: stage })
1474}
1475fn stage_native_bytes(bytes: &[u8], target: &Path) -> Result<StagedNativeFile, String> {
1476 let stage = adjacent_transaction_path(target, "stage")?;
1477 let result = (|| {
1478 let mut file = std::fs::OpenOptions::new()
1479 .write(true)
1480 .create_new(true)
1481 .open(&stage)
1482 .map_err(|error| {
1483 format!(
1484 "could not create staged registry {}: {error}",
1485 stage.display()
1486 )
1487 })?;
1488 file.write_all(bytes)
1489 .and_then(|()| file.sync_all())
1490 .map_err(|error| {
1491 format!(
1492 "could not flush staged registry {}: {error}",
1493 stage.display()
1494 )
1495 })
1496 })();
1497 if let Err(error) = result {
1498 let _ = std::fs::remove_file(&stage);
1499 return Err(error);
1500 }
1501 Ok(StagedNativeFile { path: stage })
1502}
1503
1504fn activate_native_transaction(
1505 replacements: &[NativeReplacement],
1506 removals: &[PathBuf],
1507 before_activate: &mut impl FnMut(usize, &Path) -> Result<(), String>,
1508) -> Result<(), String> {
1509 let mut affected = Vec::new();
1510 let mut seen = HashSet::new();
1511 for target in replacements
1512 .iter()
1513 .map(|replacement| &replacement.target)
1514 .chain(removals.iter())
1515 {
1516 if seen.insert(transaction_path_identity(target)) {
1517 affected.push(target.clone());
1518 }
1519 }
1520
1521 for target in &affected {
1522 match std::fs::symlink_metadata(target) {
1523 Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {}
1524 Ok(_) => {
1525 return Err(format!(
1526 "refusing to replace non-regular native package file {}",
1527 target.display()
1528 ))
1529 }
1530 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1531 Err(error) => {
1532 return Err(format!(
1533 "could not inspect native package file {}: {error}",
1534 target.display()
1535 ))
1536 }
1537 }
1538 }
1539
1540 let mut backups = Vec::new();
1541 for target in &affected {
1542 if !target.exists() {
1543 continue;
1544 }
1545 let backup = match adjacent_transaction_path(target, "backup") {
1546 Ok(backup) => backup,
1547 Err(error) => {
1548 let rollback = rollback_native_transaction(&[], &backups);
1549 return Err(transaction_error(error, rollback));
1550 }
1551 };
1552 if let Err(error) = std::fs::rename(target, &backup) {
1553 let rollback = rollback_native_transaction(&[], &backups);
1554 return Err(transaction_error(
1555 format!("could not back up {}: {error}", target.display()),
1556 rollback,
1557 ));
1558 }
1559 backups.push(NativeBackup {
1560 target: target.clone(),
1561 backup,
1562 });
1563 }
1564
1565 let mut activated = Vec::new();
1566 for (index, replacement) in replacements.iter().enumerate() {
1567 let activation = before_activate(index, &replacement.target).and_then(|()| {
1568 std::fs::rename(&replacement.staged.path, &replacement.target).map_err(|error| {
1569 format!(
1570 "could not activate replacement {}: {error}",
1571 replacement.target.display()
1572 )
1573 })
1574 });
1575 if let Err(error) = activation {
1576 let rollback = rollback_native_transaction(&activated, &backups);
1577 return Err(transaction_error(error, rollback));
1578 }
1579 activated.push(replacement.target.clone());
1580 }
1581
1582 for backup in backups {
1583 if let Err(error) = std::fs::remove_file(&backup.backup) {
1584 eprintln!(
1585 "warning: native package updated but backup {} could not be removed: {error}",
1586 backup.backup.display()
1587 );
1588 }
1589 }
1590 Ok(())
1591}
1592
1593fn rollback_native_transaction(
1594 activated: &[PathBuf],
1595 backups: &[NativeBackup],
1596) -> Result<(), String> {
1597 let mut errors = Vec::new();
1598 for target in activated.iter().rev() {
1599 match std::fs::remove_file(target) {
1600 Ok(()) => {}
1601 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1602 Err(error) => errors.push(format!("could not remove {}: {error}", target.display())),
1603 }
1604 }
1605 for backup in backups.iter().rev() {
1606 if let Err(error) = std::fs::rename(&backup.backup, &backup.target) {
1607 errors.push(format!(
1608 "could not restore {} from {}: {error}",
1609 backup.target.display(),
1610 backup.backup.display()
1611 ));
1612 }
1613 }
1614 if errors.is_empty() {
1615 Ok(())
1616 } else {
1617 Err(errors.join("; "))
1618 }
1619}
1620
1621fn transaction_error(error: String, rollback: Result<(), String>) -> String {
1622 match rollback {
1623 Ok(()) => error,
1624 Err(rollback) => format!("{error}; rollback also failed: {rollback}"),
1625 }
1626}
1627
1628fn parse_args(args: &[String]) -> Result<InstallOptions, String> {
1629 let mut package = None;
1630 let mut version = None;
1631 let mut path = None;
1632 let mut locked = false;
1633 let mut force = false;
1634 let mut build = false;
1635 let mut repository = None;
1636 let mut cdn = None;
1637 let mut i = 0;
1638 while i < args.len() {
1639 match args[i].as_str() {
1640 "--help" | "-h" => return Err(help_requested().to_string()),
1641 "--offline" => {} "--locked" => locked = true,
1643 "--force" | "-f" => force = true,
1644 "--build" => build = true,
1645 "--github" => {
1646 i += 1;
1647 repository = Some(value(args, i, "--github")?);
1648 }
1649 "--cdn" => {
1650 i += 1;
1651 cdn = Some(value(args, i, "--cdn")?);
1652 }
1653 "--version" | "-V" => {
1654 i += 1;
1655 version = Some(value(args, i, "--version")?);
1656 }
1657 "--path" => {
1658 i += 1;
1659 path = Some(PathBuf::from(value(args, i, "--path")?));
1660 }
1661 value if value.starts_with('-') => {
1662 return Err(format!("unknown install option `{value}`"));
1663 }
1664 value => {
1665 if package.replace(value.to_string()).is_some() {
1666 return Err("install accepts exactly one crate name".to_string());
1667 }
1668 }
1669 }
1670 i += 1;
1671 }
1672 if path.is_some() && version.is_some() {
1673 return Err("--path and --version cannot be used together".to_string());
1674 }
1675 if repository.is_some() && (build || path.is_some()) {
1676 return Err("--github cannot be combined with --build or --path".into());
1677 }
1678 if locked && !build && path.is_none() {
1679 return Err("--locked requires --build or --path".into());
1680 }
1681 let package = match package {
1682 Some(package) => package,
1683 None => {
1684 let path = path
1685 .as_deref()
1686 .ok_or_else(|| "missing crate name (or use --path <directory>)".to_string())?;
1687 package_name_from_manifest(path)?
1688 }
1689 };
1690 if !valid_package_name(&package) {
1691 return Err(format!("invalid Cargo package name `{package}`"));
1692 }
1693 let cdn_base = if build || path.is_some() {
1694 None
1695 } else {
1696 crate::cdn::resolve_base(cdn.as_deref())?
1697 };
1698 Ok(InstallOptions {
1699 package,
1700 version,
1701 path,
1702 locked,
1703 force,
1704 build,
1705 repository,
1706 cdn_base,
1707 })
1708}
1709
1710fn value(args: &[String], index: usize, flag: &str) -> Result<String, String> {
1711 args.get(index)
1712 .filter(|value| !value.starts_with('-'))
1713 .cloned()
1714 .ok_or_else(|| format!("{flag} requires a value"))
1715}
1716
1717fn package_name_from_manifest(path: &Path) -> Result<String, String> {
1718 let manifest = if path.is_dir() {
1719 path.join("Cargo.toml")
1720 } else {
1721 path.to_path_buf()
1722 };
1723 let output = Command::new("cargo")
1724 .args([
1725 "metadata",
1726 "--no-deps",
1727 "--format-version",
1728 "1",
1729 "--manifest-path",
1730 ])
1731 .arg(&manifest)
1732 .output()
1733 .map_err(|error| format!("could not run cargo metadata: {error}"))?;
1734 if !output.status.success() {
1735 return Err(format!(
1736 "could not inspect {}: {}",
1737 manifest.display(),
1738 String::from_utf8_lossy(&output.stderr).trim()
1739 ));
1740 }
1741 let metadata: serde_json::Value = serde_json::from_slice(&output.stdout)
1742 .map_err(|error| format!("cargo metadata returned invalid JSON: {error}"))?;
1743 let manifest = std::fs::canonicalize(&manifest)
1744 .map_err(|error| format!("could not resolve {}: {error}", manifest.display()))?;
1745 metadata
1746 .get("packages")
1747 .and_then(serde_json::Value::as_array)
1748 .and_then(|packages| {
1749 packages.iter().find_map(|package| {
1750 let package_manifest = package.get("manifest_path")?.as_str()?;
1751 let package_manifest = std::fs::canonicalize(package_manifest).ok()?;
1752 (package_manifest == manifest)
1753 .then(|| package.get("name")?.as_str().map(str::to_owned))
1754 .flatten()
1755 })
1756 })
1757 .filter(|name| valid_package_name(name))
1758 .ok_or_else(|| format!("{} has no valid [package].name", manifest.display()))
1759}
1760
1761fn valid_package_name(name: &str) -> bool {
1762 !name.is_empty()
1763 && name
1764 .bytes()
1765 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1766}
1767
1768fn help_requested() -> &'static str {
1769 "use `rpi install --help` for usage"
1770}
1771
1772pub fn print_help() {
1773 println!("Usage: rpi install [<crate>] [options]\n\nInstall or upgrade a prebuilt Rust extension using the versioned rpi-extension.json protocol.\nRepository and latest stable version are resolved from crates.io metadata.\nNewer versions upgrade automatically; an already installed version is skipped.\n\nOptions:\n --version <version> Install an exact package version\n --github <owner/repo> Use this GitHub repository; default version comes from package-specific tags\n --cdn <URL> Download release archives from this HTTPS CDN; manifests remain on GitHub\n --build Explicitly compile the extension with Cargo\n --path <directory> Build a local extension crate; crate name may be omitted\n --locked Require Cargo.lock to remain unchanged (with --build/--path)\n --force, -f Reinstall the same version or allow a downgrade/replacement\n --help, -h Show this help\n\nExamples:\n rpi install rpi-todo\n rpi install rpi-todo --version 0.2.0\n rpi install my-extension --github owner/repo --version 1.2.3\n rpi install rpi-todo --build\n rpi install --path . --force\n\nA missing or incompatible prebuilt release is an error; Cargo compilation requires --build.");
1774}
1775
1776fn print_uninstall_help() {
1777 println!(
1778 "Usage: rpi uninstall <crate>\n\nRemove a Rust cdylib extension installed by `rpi install`.\n\nOptions:\n --help, -h Show this help\n\nExample:\n rpi uninstall rpi-extension-example"
1779 );
1780}
1781
1782fn write_manifest(path: &Path, options: &InstallOptions) -> Result<(), String> {
1783 let source_dir = path
1784 .parent()
1785 .ok_or_else(|| "temporary workspace has no parent directory".to_string())?
1786 .join("src");
1787 std::fs::create_dir_all(&source_dir).map_err(|error| error.to_string())?;
1788 std::fs::write(source_dir.join("lib.rs"), "pub fn installer_marker() {}\n")
1791 .map_err(|error| error.to_string())?;
1792 let dependency = if let Some(local_path) = &options.path {
1793 let absolute = if local_path.is_absolute() {
1794 local_path.clone()
1795 } else {
1796 std::env::current_dir()
1797 .map_err(|error| error.to_string())?
1798 .join(local_path)
1799 };
1800 format!(
1801 "rpi_extension_dep = {{ package = {:?}, path = {:?} }}",
1802 options.package,
1803 absolute.display().to_string()
1804 )
1805 } else {
1806 let version = options.version.as_deref().unwrap_or("*");
1807 format!(
1808 "rpi_extension_dep = {{ package = {:?}, version = {:?} }}",
1809 options.package, version
1810 )
1811 };
1812 let contents = format!(
1813 "[package]\nname = \"{INSTALLER_MANIFEST}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[workspace]\n\n[dependencies]\n{dependency}\n"
1814 );
1815 std::fs::write(path, contents).map_err(|error| error.to_string())
1816}
1817
1818fn cargo_command(
1819 subcommand: &str,
1820 manifest: &Path,
1821 options: &InstallOptions,
1822 build: bool,
1823) -> Result<(), String> {
1824 let mut command = Command::new("cargo");
1825 command.arg(subcommand).arg("--manifest-path").arg(manifest);
1826 if build {
1827 command
1828 .arg("--package")
1829 .arg(&options.package)
1830 .arg("--release")
1831 .arg("--target-dir")
1832 .arg(manifest.parent().unwrap().join("target"));
1833 }
1834 if options.locked {
1835 command.arg("--locked");
1836 }
1837 if crate::args::offline_env_enabled() {
1838 command.arg("--offline");
1839 }
1840 let status = command
1841 .stdin(Stdio::inherit())
1842 .stdout(Stdio::inherit())
1843 .stderr(Stdio::inherit())
1844 .status()
1845 .map_err(|error| format!("could not execute cargo: {error}"))?;
1846 if status.success() {
1847 Ok(())
1848 } else {
1849 Err(format!("cargo {subcommand} exited with {status}"))
1850 }
1851}
1852
1853fn cargo_metadata(manifest: &Path, options: &InstallOptions) -> Result<CargoMetadata, String> {
1854 let mut command = Command::new("cargo");
1855 command
1856 .arg("metadata")
1857 .arg("--format-version")
1858 .arg("1")
1859 .arg("--manifest-path")
1860 .arg(manifest);
1861 if options.locked {
1862 command.arg("--locked");
1863 }
1864 if crate::args::offline_env_enabled() {
1865 command.arg("--offline");
1866 }
1867 let output = command
1868 .output()
1869 .map_err(|error| format!("could not execute cargo: {error}"))?;
1870 if !output.status.success() {
1871 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
1872 }
1873 serde_json::from_slice(&output.stdout)
1874 .map_err(|error| format!("invalid cargo metadata: {error}"))
1875}
1876
1877fn find_artifacts(release_dir: &Path, targets: &[&CargoTarget]) -> Result<Vec<PathBuf>, String> {
1878 let mut artifacts = Vec::new();
1879 for target in targets {
1880 let wanted = normalize_name(&target.name);
1881 let mut matches = Vec::new();
1882 for dir in [release_dir.to_path_buf(), release_dir.join("deps")] {
1883 let entries = std::fs::read_dir(&dir).map_err(|error| {
1884 format!("could not inspect build output {}: {error}", dir.display())
1885 })?;
1886 for entry in entries.flatten() {
1887 let path = entry.path();
1888 if !is_dynamic_library(&path) {
1889 continue;
1890 }
1891 let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
1892 continue;
1893 };
1894 let normalized = normalize_name(stem.trim_start_matches("lib"));
1895 if normalized == wanted {
1896 matches.push(path);
1897 }
1898 }
1899 }
1900 matches.sort_by_key(|path| path.components().count());
1901 let artifact = matches.into_iter().next().ok_or_else(|| {
1902 format!(
1903 "Cargo built `{}` but no cdylib artifact was found in {}",
1904 target.name,
1905 release_dir.display()
1906 )
1907 })?;
1908 artifacts.push(artifact);
1909 }
1910 Ok(artifacts)
1911}
1912
1913fn normalize_name(name: &str) -> String {
1914 name.replace('-', "_").to_ascii_lowercase()
1915}
1916
1917pub(crate) fn is_dynamic_library(path: &Path) -> bool {
1918 matches!(
1919 path.extension()
1920 .and_then(|extension| extension.to_str())
1921 .map(|extension| extension.to_ascii_lowercase())
1922 .as_deref(),
1923 Some("dll" | "so" | "dylib" | "pyd")
1924 )
1925}
1926
1927#[cfg(test)]
1928mod tests {
1929 use super::*;
1930
1931 fn args(values: &[&str]) -> Vec<String> {
1932 values.iter().map(|value| value.to_string()).collect()
1933 }
1934
1935 fn native_record(name: &str) -> InstalledNativePackage {
1936 InstalledNativePackage {
1937 name: name.to_string(),
1938 version: "1.2.3".to_string(),
1939 source: None,
1940 prebuilt: None,
1941 artifacts: vec![format!("{name}.dll")],
1942 }
1943 }
1944
1945 #[test]
1946 fn install_version_policy_allows_upgrades_and_requires_force_for_downgrades() {
1947 assert!(should_install_version("demo", None, "1.0.0", false).unwrap());
1948 for (current, requested) in [
1949 ("0.1.11", "0.1.12"),
1950 ("1.0.0-beta.1", "1.0.0"),
1951 ("1.0.0-beta.1", "1.0.0-beta.2"),
1952 ] {
1953 assert!(should_install_version("demo", Some(current), requested, false).unwrap());
1954 let error =
1955 should_install_version("demo", Some(requested), current, false).unwrap_err();
1956 assert!(error.contains("--force to downgrade"), "{error}");
1957 assert!(should_install_version("demo", Some(requested), current, true).unwrap());
1958 }
1959 for requested in ["1.2.3", "1.2.3+new-build"] {
1960 assert!(!should_install_version("demo", Some("1.2.3"), requested, false).unwrap());
1961 assert!(should_install_version("demo", Some("1.2.3"), requested, true).unwrap());
1962 }
1963 assert!(should_install_version("demo", Some("unknown"), "1.0.0", false).is_err());
1964 assert!(should_install_version("demo", Some("unknown"), "1.0.0", true).unwrap());
1965 }
1966
1967 #[test]
1968 fn native_install_same_version_is_unchanged_and_force_reinstalls_or_downgrades() {
1969 let temp = tempfile::tempdir().unwrap();
1970 let destination = temp.path().join("extensions");
1971 let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
1972 let build = temp.path().join("demo.dll");
1973 std::fs::create_dir_all(&destination).unwrap();
1974 std::fs::write(destination.join("demo.dll"), b"installed").unwrap();
1975 let record = native_record("demo");
1976 write_native_packages_at(&metadata, &[record.clone()]).unwrap();
1977 let original_registry = std::fs::read(&metadata).unwrap();
1978 std::fs::write(&build, b"rebuilt").unwrap();
1979
1980 assert!(install_native_package_at(
1981 &destination,
1982 &metadata,
1983 record.clone(),
1984 &[build.clone()],
1985 false,
1986 )
1987 .unwrap()
1988 .is_empty());
1989 assert_eq!(
1990 std::fs::read(destination.join("demo.dll")).unwrap(),
1991 b"installed"
1992 );
1993 assert_eq!(std::fs::read(&metadata).unwrap(), original_registry);
1994 let mut older = record.clone();
1995 older.version = "1.0.0".into();
1996 let error = install_native_package_at(
1997 &destination,
1998 &metadata,
1999 older.clone(),
2000 &[build.clone()],
2001 false,
2002 )
2003 .unwrap_err();
2004 assert!(error.contains("--force to downgrade"));
2005 assert_eq!(
2006 std::fs::read(destination.join("demo.dll")).unwrap(),
2007 b"installed"
2008 );
2009 assert_eq!(std::fs::read(&metadata).unwrap(), original_registry);
2010
2011 for forced in [record, older] {
2012 install_native_package_at(
2013 &destination,
2014 &metadata,
2015 forced.clone(),
2016 &[build.clone()],
2017 true,
2018 )
2019 .unwrap();
2020 let records = read_native_package_registry_at(&metadata)
2021 .unwrap()
2022 .into_records();
2023 assert_eq!(records[0].version, forced.version);
2024 let active = destination.join(&records[0].artifacts[0]);
2025 assert_eq!(std::fs::read(active).unwrap(), b"rebuilt");
2026 assert_eq!(
2027 std::fs::read(destination.join("demo.dll")).unwrap(),
2028 b"installed"
2029 );
2030 }
2031 assert_no_native_transaction_files(&destination);
2032 }
2033
2034 #[test]
2035 fn native_upgrade_does_not_overwrite_unowned_artifacts() {
2036 let temp = tempfile::tempdir().unwrap();
2037 let destination = temp.path().join("extensions");
2038 let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
2039 std::fs::create_dir_all(&destination).unwrap();
2040 std::fs::write(destination.join("demo.dll"), b"installed").unwrap();
2041 std::fs::write(destination.join("unowned.dll"), b"unowned").unwrap();
2042 write_native_packages_at(&metadata, &[native_record("demo")]).unwrap();
2043 let original_registry = std::fs::read(&metadata).unwrap();
2044 let artifact = temp.path().join("build-unowned.dll");
2045 std::fs::write(&artifact, b"new").unwrap();
2046 let mut upgraded = native_record("demo");
2047 upgraded.version = "2.0.0".into();
2048 let installed =
2049 install_native_package_at(&destination, &metadata, upgraded, &[artifact], false)
2050 .unwrap();
2051 assert_eq!(installed.len(), 1);
2052 assert_eq!(
2053 std::fs::read(destination.join("demo.dll")).unwrap(),
2054 b"installed"
2055 );
2056 assert_eq!(
2057 std::fs::read(destination.join("unowned.dll")).unwrap(),
2058 b"unowned"
2059 );
2060 assert_ne!(std::fs::read(&metadata).unwrap(), original_registry);
2061 assert_no_native_transaction_files(&destination);
2062 }
2063
2064 struct TempAgent {
2065 _guard: std::sync::MutexGuard<'static, ()>,
2066 temp: tempfile::TempDir,
2067 previous: Option<std::ffi::OsString>,
2068 }
2069
2070 impl TempAgent {
2071 fn new() -> Self {
2072 let guard = crate::config::test_support::env_lock()
2073 .lock()
2074 .unwrap_or_else(|poisoned| poisoned.into_inner());
2075 let previous = std::env::var_os(crate::config::CONFIG_DIR_ENV);
2076 let temp = tempfile::tempdir().unwrap();
2077 std::env::set_var(crate::config::CONFIG_DIR_ENV, temp.path());
2078 Self {
2079 _guard: guard,
2080 temp,
2081 previous,
2082 }
2083 }
2084
2085 fn metadata_path(&self) -> PathBuf {
2086 self.temp.path().join(NATIVE_PACKAGES_FILE)
2087 }
2088 }
2089
2090 impl Drop for TempAgent {
2091 fn drop(&mut self) {
2092 match self.previous.take() {
2093 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2094 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2095 }
2096 }
2097 }
2098
2099 #[test]
2100 fn parses_registry_package_and_options() {
2101 let parsed = parse_args(&args(&["my-extension", "--version", "1.2.3", "--force"])).unwrap();
2102 assert_eq!(parsed.package, "my-extension");
2103 assert_eq!(parsed.version.as_deref(), Some("1.2.3"));
2104 assert!(parsed.force);
2105 }
2106
2107 #[test]
2108 fn github_override_and_source_build_are_explicit_options() {
2109 let custom = parse_args(&args(&[
2110 "my-extension",
2111 "--github",
2112 "publisher/monorepo",
2113 "--version",
2114 "1.2.3",
2115 ]))
2116 .unwrap();
2117 assert_eq!(custom.repository.as_deref(), Some("publisher/monorepo"));
2118 assert_eq!(custom.version.as_deref(), Some("1.2.3"));
2119 assert!(!custom.build);
2120 let custom_url = parse_args(&args(&[
2121 "my-extension",
2122 "--github",
2123 "https://github.com/publisher/monorepo",
2124 ]))
2125 .unwrap();
2126 assert!(custom_url.repository.is_some());
2127 assert!(
2128 parse_args(&args(&["my-extension", "--build", "--locked"]))
2129 .unwrap()
2130 .build
2131 );
2132 assert!(parse_args(&args(&["my-extension", "--offline"])).is_ok());
2133 assert!(parse_args(&args(&[
2134 "my-extension",
2135 "--github",
2136 "owner/repo",
2137 "--build"
2138 ]))
2139 .is_err());
2140 assert!(parse_args(&args(&[
2141 "my-extension",
2142 "--github",
2143 "owner/repo",
2144 "--path",
2145 "."
2146 ]))
2147 .is_err());
2148 assert!(parse_args(&args(&["my-extension", "--locked"])).is_err());
2149 }
2150
2151 #[test]
2152 fn synchronous_source_backend_cannot_silently_compile_a_prebuilt_request() {
2153 let _agent = TempAgent::new();
2154 assert_eq!(run(&args(&["demo"])), 2);
2155 assert_eq!(
2156 run(&args(&[
2157 "demo",
2158 "--github",
2159 "publisher/repo",
2160 "--version",
2161 "1.2.3"
2162 ])),
2163 2
2164 );
2165 }
2166
2167 #[test]
2168 fn uninstall_prebuilt_package_uses_its_recorded_library_name() {
2169 let agent = TempAgent::new();
2170 let directory = agent.temp.path().join("extensions");
2171 std::fs::create_dir_all(&directory).unwrap();
2172 std::fs::write(directory.join("different_library.dll"), b"prebuilt library").unwrap();
2173 let record: InstalledNativePackage = serde_json::from_value(serde_json::json!({
2174 "name":"demo", "version":"1.2.3", "source":"prebuilt:https://github.com/owner/repo",
2175 "artifacts":["different_library.dll"],
2176 "prebuilt":{"repository":"https://github.com/owner/repo", "target":"x86_64-pc-windows-msvc",
2177 "manifest_sha256":"a".repeat(64),"source_commit":"b".repeat(40),"sdk_version":"0.3.23","plugin_abi":4,
2178 "archive_sha256":"c".repeat(64),"library_sha256":"d".repeat(64),"custom_repository":true}
2179 })).unwrap();
2180 write_native_packages_at(&agent.metadata_path(), &[record]).unwrap();
2181 assert_eq!(uninstall(&args(&["demo"])), 0);
2182 assert!(!directory.join("different_library.dll").exists());
2183 assert!(installed_native_packages_strict().unwrap().is_empty());
2184 }
2185
2186 #[test]
2187 fn parses_local_package() {
2188 let parsed = parse_args(&args(&["--path", "../extension", "my-extension"])).unwrap();
2189 assert_eq!(parsed.path, Some(PathBuf::from("../extension")));
2190 }
2191
2192 #[test]
2193 fn local_path_can_infer_package_name() {
2194 let parsed = parse_args(&args(&["--path", ".", "--force"])).unwrap();
2195 assert!(!parsed.package.is_empty());
2196 assert!(valid_package_name(&parsed.package));
2197 assert!(parsed.force);
2198 }
2199
2200 #[test]
2201 fn rejects_non_extension_options_and_invalid_names() {
2202 assert!(parse_args(&args(&["my.extension"])).is_err());
2203 assert!(parse_args(&args(&["my-extension", "--unknown"])).is_err());
2204 assert!(parse_args(&args(&["my-extension", "--path", ".", "--version", "1"])).is_err());
2205 }
2206
2207 #[test]
2208 fn cdylib_hint_recommends_unified_and_documents_legacy_compatibility() {
2209 assert!(CDYLIB_EXTENSION_HINT.contains("rpi_plugin_register"));
2210 assert!(CDYLIB_EXTENSION_HINT.contains("rpi_plugin_register_v2"));
2211 assert!(CDYLIB_EXTENSION_HINT.contains("rpi_plugin_register_v3"));
2212 }
2213
2214 #[test]
2215 fn registry_read_distinguishes_missing_and_valid_files() {
2216 let temp = tempfile::tempdir().unwrap();
2217 let path = temp.path().join(NATIVE_PACKAGES_FILE);
2218
2219 assert_eq!(
2220 read_native_package_registry_at(&path).unwrap(),
2221 NativePackageRegistry::Missing
2222 );
2223
2224 std::fs::write(
2225 &path,
2226 br#"[{"name":"demo","version":"1.2.3","source":null}]"#,
2227 )
2228 .unwrap();
2229 assert_eq!(
2230 read_native_package_registry_at(&path).unwrap(),
2231 NativePackageRegistry::Valid(vec![InstalledNativePackage {
2232 name: "demo".to_string(),
2233 version: "1.2.3".to_string(),
2234 source: None,
2235 prebuilt: None,
2236 artifacts: Vec::new(),
2237 }])
2238 );
2239 }
2240
2241 #[test]
2242 fn active_global_artifacts_exclude_retired_versioned_files() {
2243 let agent = tempfile::tempdir().unwrap();
2244 let extensions = agent.path().join("extensions");
2245 std::fs::create_dir_all(&extensions).unwrap();
2246 let active = "libdemo-v2.0.0-0123456789abcdef0123456789abcdef.dll";
2247 let retired = "libdemo-v1.0.0-abcdef0123456789abcdef0123456789.dll";
2248 std::fs::write(extensions.join(active), b"active").unwrap();
2249 std::fs::write(extensions.join(retired), b"retired").unwrap();
2250 std::fs::write(extensions.join("legacy.dll"), b"legacy").unwrap();
2251 write_native_packages_at(
2252 &agent.path().join(NATIVE_PACKAGES_FILE),
2253 &[InstalledNativePackage {
2254 name: "demo".into(),
2255 version: "2.0.0".into(),
2256 source: None,
2257 prebuilt: None,
2258 artifacts: vec![active.into()],
2259 }],
2260 )
2261 .unwrap();
2262
2263 assert_eq!(
2264 active_global_native_package_artifacts_at(agent.path()).unwrap(),
2265 vec![extensions.join(active)]
2266 );
2267 assert_eq!(
2268 unregistered_global_native_package_artifacts_at(agent.path()).unwrap(),
2269 vec![extensions.join("legacy.dll")]
2270 );
2271 }
2272
2273 #[test]
2274 fn registry_read_rejects_corrupt_json() {
2275 let temp = tempfile::tempdir().unwrap();
2276 let path = temp.path().join(NATIVE_PACKAGES_FILE);
2277 std::fs::write(&path, b"[{").unwrap();
2278
2279 let error = read_native_package_registry_at(&path).unwrap_err();
2280 assert!(error.contains("invalid JSON or schema"));
2281 }
2282
2283 #[test]
2284 fn registry_read_rejects_wrong_schema() {
2285 let temp = tempfile::tempdir().unwrap();
2286 let path = temp.path().join(NATIVE_PACKAGES_FILE);
2287 std::fs::write(&path, br#"{"name":"demo","version":"1.2.3"}"#).unwrap();
2288
2289 let error = read_native_package_registry_at(&path).unwrap_err();
2290 assert!(error.contains("invalid JSON or schema"));
2291 }
2292
2293 #[test]
2294 fn record_does_not_replace_a_corrupt_registry() {
2295 let temp = tempfile::tempdir().unwrap();
2296 let path = temp.path().join(NATIVE_PACKAGES_FILE);
2297 let original = b"[{broken metadata";
2298 std::fs::write(&path, original).unwrap();
2299
2300 assert!(record_native_package_at(&path, &native_record("demo")).is_err());
2301 assert_eq!(std::fs::read(&path).unwrap(), original);
2302 }
2303
2304 #[test]
2305 fn native_install_transaction_preserves_loaded_artifacts_and_publishes_new_ones() {
2306 let temp = tempfile::tempdir().unwrap();
2307 let destination = temp.path().join("extensions");
2308 let metadata_path = temp.path().join(NATIVE_PACKAGES_FILE);
2309 let build = temp.path().join("build");
2310 std::fs::create_dir_all(&destination).unwrap();
2311 std::fs::create_dir_all(&build).unwrap();
2312 std::fs::write(destination.join("old.dll"), b"old-only").unwrap();
2313 std::fs::write(destination.join("same.dll"), b"old-shared").unwrap();
2314 let old = InstalledNativePackage {
2315 name: "demo".into(),
2316 version: "1.0.0".into(),
2317 source: None,
2318 prebuilt: None,
2319 artifacts: vec!["old.dll".into(), "same.dll".into()],
2320 };
2321 write_native_packages_at(&metadata_path, &[old]).unwrap();
2322 std::fs::write(build.join("new.dll"), b"new-only").unwrap();
2323 std::fs::write(build.join("same.dll"), b"new-shared").unwrap();
2324
2325 let installed = install_native_package_at(
2326 &destination,
2327 &metadata_path,
2328 InstalledNativePackage {
2329 name: "demo".into(),
2330 version: "2.0.0".into(),
2331 source: None,
2332 prebuilt: None,
2333 artifacts: Vec::new(),
2334 },
2335 &[build.join("new.dll"), build.join("same.dll")],
2336 false,
2337 )
2338 .unwrap();
2339
2340 assert_eq!(installed.len(), 2);
2341 assert!(installed.iter().all(|path| path.exists()));
2342 assert_eq!(
2343 std::fs::read(destination.join("old.dll")).unwrap(),
2344 b"old-only"
2345 );
2346 assert_eq!(
2347 std::fs::read(destination.join("same.dll")).unwrap(),
2348 b"old-shared"
2349 );
2350 assert!(installed
2351 .iter()
2352 .any(|path| { std::fs::read(path).ok().as_deref() == Some(b"new-only".as_slice()) }));
2353 assert!(installed
2354 .iter()
2355 .any(|path| { std::fs::read(path).ok().as_deref() == Some(b"new-shared".as_slice()) }));
2356 let records = read_native_package_registry_at(&metadata_path)
2357 .unwrap()
2358 .into_records();
2359 assert_eq!(records.len(), 1);
2360 assert_eq!(records[0].version, "2.0.0");
2361 let active_names = installed
2362 .iter()
2363 .map(|path| path.file_name().unwrap().to_string_lossy().into_owned())
2364 .collect::<Vec<_>>();
2365 assert_eq!(records[0].artifacts, active_names);
2366 assert_no_native_transaction_files(temp.path());
2367 assert_no_native_transaction_files(&destination);
2368 }
2369
2370 #[test]
2371 fn native_install_transaction_rolls_back_artifacts_and_registry() {
2372 let temp = tempfile::tempdir().unwrap();
2373 let destination = temp.path().join("extensions");
2374 let metadata_path = temp.path().join(NATIVE_PACKAGES_FILE);
2375 let build = temp.path().join("build");
2376 std::fs::create_dir_all(&destination).unwrap();
2377 std::fs::create_dir_all(&build).unwrap();
2378 std::fs::write(destination.join("old.dll"), b"old-only").unwrap();
2379 std::fs::write(destination.join("same.dll"), b"old-shared").unwrap();
2380 let old = InstalledNativePackage {
2381 name: "demo".into(),
2382 version: "1.0.0".into(),
2383 source: None,
2384 prebuilt: None,
2385 artifacts: vec!["old.dll".into(), "same.dll".into()],
2386 };
2387 write_native_packages_at(&metadata_path, &[old]).unwrap();
2388 let original_registry = std::fs::read(&metadata_path).unwrap();
2389 std::fs::write(build.join("new.dll"), b"new-only").unwrap();
2390 std::fs::write(build.join("same.dll"), b"new-shared").unwrap();
2391
2392 let error = install_native_package_at_with_hook(
2393 &destination,
2394 &metadata_path,
2395 InstalledNativePackage {
2396 name: "demo".into(),
2397 version: "2.0.0".into(),
2398 source: None,
2399 prebuilt: None,
2400 artifacts: Vec::new(),
2401 },
2402 &[build.join("new.dll"), build.join("same.dll")],
2403 &[],
2404 false,
2405 |_, target| {
2406 if target == metadata_path {
2407 Err("injected registry activation failure".into())
2408 } else {
2409 Ok(())
2410 }
2411 },
2412 )
2413 .unwrap_err();
2414
2415 assert!(error.contains("injected registry activation failure"));
2416 assert_eq!(std::fs::read(&metadata_path).unwrap(), original_registry);
2417 assert_eq!(
2418 std::fs::read(destination.join("old.dll")).unwrap(),
2419 b"old-only"
2420 );
2421 assert_eq!(
2422 std::fs::read(destination.join("same.dll")).unwrap(),
2423 b"old-shared"
2424 );
2425 assert!(!destination.join("new.dll").exists());
2426 assert_no_native_transaction_files(temp.path());
2427 assert_no_native_transaction_files(&destination);
2428 }
2429
2430 fn assert_no_native_transaction_files(directory: &Path) {
2431 for entry in std::fs::read_dir(directory).unwrap() {
2432 let name = entry.unwrap().file_name().to_string_lossy().into_owned();
2433 assert!(!name.contains(".rpi-stage-"), "left staged file: {name}");
2434 assert!(!name.contains(".rpi-backup-"), "left backup file: {name}");
2435 assert!(
2436 !name.contains(".rpi-assets-stage-"),
2437 "left asset stage: {name}"
2438 );
2439 }
2440 }
2441
2442 #[test]
2443 fn best_effort_registry_read_omits_corrupt_metadata() {
2444 let agent = TempAgent::new();
2445 std::fs::write(agent.metadata_path(), b"[{broken metadata").unwrap();
2446
2447 assert!(installed_native_packages().is_empty());
2448 }
2449
2450 #[test]
2451 fn install_fails_closed_before_work_when_registry_is_corrupt() {
2452 let agent = TempAgent::new();
2453 let path = agent.metadata_path();
2454 let original = b"[{broken metadata";
2455 std::fs::write(&path, original).unwrap();
2456
2457 assert_eq!(run(&args(&["demo"])), 1);
2458 assert_eq!(std::fs::read(&path).unwrap(), original);
2459 assert!(!agent.temp.path().join("extensions").exists());
2460 }
2461
2462 #[test]
2463 fn uninstall_fails_closed_without_removing_artifacts() {
2464 let agent = TempAgent::new();
2465 let path = agent.metadata_path();
2466 let original = b"[{broken metadata";
2467 std::fs::write(&path, original).unwrap();
2468 let extension_dir = agent.temp.path().join("extensions");
2469 std::fs::create_dir_all(&extension_dir).unwrap();
2470 let artifact = extension_dir.join("demo.dll");
2471 std::fs::write(&artifact, b"extension").unwrap();
2472
2473 assert_eq!(uninstall(&args(&["demo"])), 1);
2474 assert_eq!(std::fs::read(&path).unwrap(), original);
2475 assert_eq!(std::fs::read(artifact).unwrap(), b"extension");
2476 }
2477
2478 #[test]
2479 fn uninstall_commits_artifact_and_registry_changes_together() {
2480 let agent = TempAgent::new();
2481 let extension_dir = agent.temp.path().join("extensions");
2482 std::fs::create_dir_all(&extension_dir).unwrap();
2483 std::fs::write(extension_dir.join("demo.dll"), b"demo").unwrap();
2484 std::fs::write(extension_dir.join("other.dll"), b"other").unwrap();
2485 write_native_packages_at(
2486 &agent.metadata_path(),
2487 &[native_record("demo"), native_record("other")],
2488 )
2489 .unwrap();
2490
2491 assert_eq!(uninstall(&args(&["demo"])), 0);
2492 assert!(!extension_dir.join("demo.dll").exists());
2493 assert_eq!(
2494 std::fs::read(extension_dir.join("other.dll")).unwrap(),
2495 b"other"
2496 );
2497 assert_eq!(
2498 read_native_package_registry_at(&agent.metadata_path())
2499 .unwrap()
2500 .into_records(),
2501 vec![native_record("other")]
2502 );
2503 assert_no_native_transaction_files(agent.temp.path());
2504 assert_no_native_transaction_files(&extension_dir);
2505 }
2506
2507 #[test]
2508 fn uninstall_uses_recorded_artifacts_without_name_fallback() {
2509 let agent = TempAgent::new();
2510 let extension_dir = agent.temp.path().join("extensions");
2511 std::fs::create_dir_all(&extension_dir).unwrap();
2512 std::fs::write(extension_dir.join("recorded.dll"), b"managed").unwrap();
2513 std::fs::write(extension_dir.join("demo.dll"), b"unmanaged").unwrap();
2514 write_native_packages_at(
2515 &agent.metadata_path(),
2516 &[InstalledNativePackage {
2517 name: "demo".into(),
2518 version: "1.2.3".into(),
2519 source: None,
2520 prebuilt: None,
2521 artifacts: vec!["recorded.dll".into()],
2522 }],
2523 )
2524 .unwrap();
2525
2526 assert_eq!(uninstall(&args(&["demo"])), 0);
2527 assert!(!extension_dir.join("recorded.dll").exists());
2528 assert_eq!(
2529 std::fs::read(extension_dir.join("demo.dll")).unwrap(),
2530 b"unmanaged"
2531 );
2532 assert!(!agent.metadata_path().exists());
2533 assert_no_native_transaction_files(agent.temp.path());
2534 assert_no_native_transaction_files(&extension_dir);
2535 }
2536
2537 fn prebuilt_record(
2539 name: &str,
2540 version: &str,
2541 assets: &[(&str, &[u8])],
2542 ) -> InstalledNativePackage {
2543 InstalledNativePackage {
2544 name: name.into(),
2545 version: version.into(),
2546 source: Some(format!("prebuilt:https://github.com/owner/{name}")),
2547 prebuilt: Some(crate::prebuilt_extensions::PrebuiltRecord {
2548 repository: format!("https://github.com/owner/{name}"),
2549 target: "x86_64-unknown-linux-gnu".into(),
2550 manifest_sha256: "a".repeat(64),
2551 source_commit: "b".repeat(40),
2552 sdk_version: "0.3.23".into(),
2553 plugin_abi: 4,
2554 archive_sha256: "c".repeat(64),
2555 library_sha256: "d".repeat(64),
2556 custom_repository: false,
2557 assets: assets
2558 .iter()
2559 .map(|(path, bytes)| crate::prebuilt_extensions::PrebuiltAsset {
2560 path: (*path).into(),
2561 sha256: crate::prebuilt_extensions::hash(bytes),
2562 })
2563 .collect(),
2564 }),
2565 artifacts: Vec::new(),
2566 }
2567 }
2568
2569 fn sidecars(assets: &[(&str, &[u8])]) -> Vec<NativeAssetFile> {
2570 assets
2571 .iter()
2572 .map(|(path, bytes)| NativeAssetFile {
2573 path: (*path).into(),
2574 bytes: bytes.to_vec(),
2575 })
2576 .collect()
2577 }
2578
2579 #[test]
2580 fn native_asset_upgrade_replaces_sidecars_and_prunes_stale_ones() {
2581 let temp = tempfile::tempdir().unwrap();
2582 let destination = temp.path().join("extensions");
2583 let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
2584 let library = temp.path().join("libdemo.so");
2585 std::fs::write(&library, b"library v1").unwrap();
2586 let v1 = [
2587 ("runtime/main.mjs", &b"v1"[..]),
2588 ("runtime/old.mjs", &b"old"[..]),
2589 ];
2590 install_native_package_with_assets_at(
2591 &destination,
2592 &metadata,
2593 prebuilt_record("demo", "1.0.0", &v1),
2594 std::slice::from_ref(&library),
2595 &sidecars(&v1),
2596 false,
2597 )
2598 .unwrap();
2599 assert_eq!(
2600 std::fs::read(destination.join("runtime/old.mjs")).unwrap(),
2601 b"old"
2602 );
2603
2604 std::fs::write(&library, b"library v2").unwrap();
2605 let v2 = [
2606 ("runtime/main.mjs", &b"v2"[..]),
2607 ("runtime/deep/lib.mjs", &b"deep"[..]),
2608 ];
2609 let installed = install_native_package_with_assets_at(
2610 &destination,
2611 &metadata,
2612 prebuilt_record("demo", "2.0.0", &v2),
2613 std::slice::from_ref(&library),
2614 &sidecars(&v2),
2615 false,
2616 )
2617 .unwrap();
2618 assert_eq!(installed.len(), 3);
2619 let active_library = installed
2620 .iter()
2621 .find(|path| is_dynamic_library(path))
2622 .expect("installed library");
2623 assert_ne!(active_library, &destination.join("libdemo.so"));
2624 assert_eq!(std::fs::read(active_library).unwrap(), b"library v2");
2625 assert_eq!(
2626 std::fs::read(destination.join("libdemo.so")).unwrap(),
2627 b"library v1"
2628 );
2629 assert_eq!(
2630 std::fs::read(destination.join("runtime/main.mjs")).unwrap(),
2631 b"v2"
2632 );
2633 assert_eq!(
2634 std::fs::read(destination.join("runtime/deep/lib.mjs")).unwrap(),
2635 b"deep"
2636 );
2637 assert!(!destination.join("runtime/old.mjs").exists());
2638 let records = read_native_package_registry_at(&metadata)
2639 .unwrap()
2640 .into_records();
2641 assert_eq!(records[0].version, "2.0.0");
2642 let ledger: Vec<&str> = recorded_asset_paths(&records[0]);
2643 assert_eq!(ledger, vec!["runtime/main.mjs", "runtime/deep/lib.mjs"]);
2644 install_native_package_with_assets_at(
2646 &destination,
2647 &metadata,
2648 prebuilt_record("demo", "3.0.0", &[]),
2649 std::slice::from_ref(&library),
2650 &[],
2651 false,
2652 )
2653 .unwrap();
2654 assert!(!destination.join("runtime").exists());
2655 let records = read_native_package_registry_at(&metadata)
2656 .unwrap()
2657 .into_records();
2658 let active_library = destination.join(&records[0].artifacts[0]);
2659 assert_eq!(std::fs::read(active_library).unwrap(), b"library v2");
2660 assert_eq!(
2661 std::fs::read(destination.join("libdemo.so")).unwrap(),
2662 b"library v1"
2663 );
2664 assert_no_native_transaction_files(temp.path());
2665 assert_no_native_transaction_files(&destination);
2666 }
2667
2668 #[test]
2669 fn native_asset_failure_during_activation_restores_the_previous_install() {
2670 let temp = tempfile::tempdir().unwrap();
2671 let destination = temp.path().join("extensions");
2672 let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
2673 let library = temp.path().join("libdemo.so");
2674 std::fs::write(&library, b"old library").unwrap();
2675 let v1 = [("runtime/main.mjs", &b"v1"[..])];
2676 install_native_package_with_assets_at(
2677 &destination,
2678 &metadata,
2679 prebuilt_record("demo", "1.0.0", &v1),
2680 std::slice::from_ref(&library),
2681 &sidecars(&v1),
2682 false,
2683 )
2684 .unwrap();
2685 let original_registry = std::fs::read(&metadata).unwrap();
2686
2687 std::fs::write(&library, b"new library").unwrap();
2688 let v2 = [
2689 ("runtime/main.mjs", &b"v2"[..]),
2690 ("runtime/extra.mjs", &b"extra"[..]),
2691 ];
2692 let error = install_native_package_at_with_hook(
2694 &destination,
2695 &metadata,
2696 prebuilt_record("demo", "2.0.0", &v2),
2697 std::slice::from_ref(&library),
2698 &sidecars(&v2),
2699 false,
2700 |index, _| {
2701 if index == 2 {
2702 Err("injected sidecar activation failure".into())
2703 } else {
2704 Ok(())
2705 }
2706 },
2707 )
2708 .unwrap_err();
2709
2710 assert!(
2711 error.contains("injected sidecar activation failure"),
2712 "{error}"
2713 );
2714 assert_eq!(
2715 std::fs::read(destination.join("libdemo.so")).unwrap(),
2716 b"old library"
2717 );
2718 assert_eq!(
2719 std::fs::read(destination.join("runtime/main.mjs")).unwrap(),
2720 b"v1"
2721 );
2722 assert!(!destination.join("runtime/extra.mjs").exists());
2723 assert_eq!(std::fs::read(&metadata).unwrap(), original_registry);
2724 assert_no_native_transaction_files(temp.path());
2725 assert_no_native_transaction_files(&destination);
2726 assert_no_native_transaction_files(&destination.join("runtime"));
2727
2728 install_native_package_with_assets_at(
2730 &destination,
2731 &metadata,
2732 prebuilt_record("demo", "2.0.0", &v2),
2733 std::slice::from_ref(&library),
2734 &sidecars(&v2),
2735 false,
2736 )
2737 .unwrap();
2738 assert_eq!(
2739 std::fs::read(destination.join("runtime/main.mjs")).unwrap(),
2740 b"v2"
2741 );
2742 assert_eq!(
2743 std::fs::read(destination.join("runtime/extra.mjs")).unwrap(),
2744 b"extra"
2745 );
2746 }
2747
2748 #[test]
2749 fn native_asset_conflicts_and_overlaps_fail_before_any_write() {
2750 let temp = tempfile::tempdir().unwrap();
2751 let destination = temp.path().join("extensions");
2752 let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
2753 let library = temp.path().join("libdemo.so");
2754 std::fs::write(&library, b"demo library").unwrap();
2755 let other = temp.path().join("libother.so");
2756 std::fs::write(&other, b"other library").unwrap();
2757 let shared = [("runtime/shared.mjs", &b"other"[..])];
2758 install_native_package_with_assets_at(
2759 &destination,
2760 &metadata,
2761 prebuilt_record("other", "1.0.0", &shared),
2762 std::slice::from_ref(&other),
2763 &sidecars(&shared),
2764 false,
2765 )
2766 .unwrap();
2767 let original_registry = std::fs::read(&metadata).unwrap();
2768
2769 let attempts: [&[(&str, &[u8])]; 3] = [
2770 &[("runtime/shared.mjs", &b"demo"[..])],
2771 &[("Runtime/Shared.mjs", &b"demo"[..])],
2772 &[("runtime", &b"file"[..]), ("runtime/own.mjs", &b"own"[..])],
2773 ];
2774 for assets in attempts {
2775 let error = install_native_package_with_assets_at(
2776 &destination,
2777 &metadata,
2778 prebuilt_record("demo", "1.0.0", assets),
2779 std::slice::from_ref(&library),
2780 &sidecars(assets),
2781 false,
2782 )
2783 .unwrap_err();
2784 assert!(
2785 error.contains("conflicts with asset")
2786 || error.contains("duplicate or overlapping"),
2787 "{error}"
2788 );
2789 assert!(!destination.join("libdemo.so").exists());
2790 assert_eq!(
2791 std::fs::read(destination.join("runtime/shared.mjs")).unwrap(),
2792 b"other"
2793 );
2794 assert_eq!(std::fs::read(&metadata).unwrap(), original_registry);
2795 assert_eq!(
2796 std::fs::read_dir(destination.join("runtime"))
2797 .unwrap()
2798 .count(),
2799 1
2800 );
2801 assert_no_native_transaction_files(&destination);
2802 }
2803 }
2804
2805 #[test]
2806 fn native_asset_replacing_an_unowned_file_requires_force() {
2807 let temp = tempfile::tempdir().unwrap();
2808 let destination = temp.path().join("extensions");
2809 let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
2810 let library = temp.path().join("libdemo.so");
2811 std::fs::write(&library, b"demo library").unwrap();
2812 std::fs::create_dir_all(destination.join("runtime")).unwrap();
2813 std::fs::write(destination.join("runtime/main.mjs"), b"user file").unwrap();
2814 let assets = [("runtime/main.mjs", &b"demo"[..])];
2815
2816 let error = install_native_package_with_assets_at(
2817 &destination,
2818 &metadata,
2819 prebuilt_record("demo", "1.0.0", &assets),
2820 std::slice::from_ref(&library),
2821 &sidecars(&assets),
2822 false,
2823 )
2824 .unwrap_err();
2825 assert!(error.contains("use --force to replace it"), "{error}");
2826 assert_eq!(
2827 std::fs::read(destination.join("runtime/main.mjs")).unwrap(),
2828 b"user file"
2829 );
2830 assert!(!metadata.exists());
2831
2832 install_native_package_with_assets_at(
2833 &destination,
2834 &metadata,
2835 prebuilt_record("demo", "1.0.0", &assets),
2836 std::slice::from_ref(&library),
2837 &sidecars(&assets),
2838 true,
2839 )
2840 .unwrap();
2841 assert_eq!(
2842 std::fs::read(destination.join("runtime/main.mjs")).unwrap(),
2843 b"demo"
2844 );
2845 }
2846
2847 #[test]
2848 fn uninstall_removes_recorded_sidecars_and_only_the_directories_they_empty() {
2849 let agent = TempAgent::new();
2850 let extension_dir = agent.temp.path().join("extensions");
2851 std::fs::create_dir_all(extension_dir.join("runtime/deep")).unwrap();
2852 std::fs::write(extension_dir.join("libdemo.so"), b"library").unwrap();
2853 std::fs::write(extension_dir.join("runtime/main.mjs"), b"main").unwrap();
2854 std::fs::write(extension_dir.join("runtime/deep/lib.mjs"), b"deep").unwrap();
2855 std::fs::write(extension_dir.join("runtime/user.mjs"), b"user").unwrap();
2856 let mut record = prebuilt_record(
2857 "demo",
2858 "1.0.0",
2859 &[
2860 ("runtime/main.mjs", &b"main"[..]),
2861 ("runtime/deep/lib.mjs", &b"deep"[..]),
2862 ],
2863 );
2864 record.artifacts = vec!["libdemo.so".into()];
2865 write_native_packages_at(&agent.metadata_path(), &[record]).unwrap();
2866
2867 assert_eq!(uninstall(&args(&["demo"])), 0);
2868 assert!(!extension_dir.join("libdemo.so").exists());
2869 assert!(!extension_dir.join("runtime/main.mjs").exists());
2870 assert!(!extension_dir.join("runtime/deep").exists());
2871 assert_eq!(
2872 std::fs::read(extension_dir.join("runtime/user.mjs")).unwrap(),
2873 b"user"
2874 );
2875 assert!(!agent.metadata_path().exists());
2876 assert_no_native_transaction_files(agent.temp.path());
2877 assert_no_native_transaction_files(&extension_dir);
2878 }
2879
2880 #[test]
2881 fn native_asset_case_only_rename_prunes_the_old_sidecar() {
2882 let temp = tempfile::tempdir().unwrap();
2883 let destination = temp.path().join("extensions");
2884 let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
2885 let library = temp.path().join("libdemo.so");
2886 std::fs::write(&library, b"library v1").unwrap();
2887 let first = [("Runtime/main.mjs", &b"worker v1"[..])];
2888 install_native_package_with_assets_at(
2889 &destination,
2890 &metadata,
2891 prebuilt_record("demo", "1.0.0", &first),
2892 std::slice::from_ref(&library),
2893 &sidecars(&first),
2894 false,
2895 )
2896 .unwrap();
2897 let second = [("runtime/main.mjs", &b"worker v2"[..])];
2898 install_native_package_with_assets_at(
2899 &destination,
2900 &metadata,
2901 prebuilt_record("demo", "1.1.0", &second),
2902 std::slice::from_ref(&library),
2903 &sidecars(&second),
2904 false,
2905 )
2906 .unwrap();
2907
2908 assert_eq!(
2909 std::fs::read(destination.join("runtime/main.mjs")).unwrap(),
2910 b"worker v2"
2911 );
2912 fn store_files(dir: &Path) -> usize {
2914 std::fs::read_dir(dir)
2915 .unwrap()
2916 .map(|entry| {
2917 let path = entry.unwrap().path();
2918 if path.is_dir() {
2919 store_files(&path)
2920 } else {
2921 1
2922 }
2923 })
2924 .sum()
2925 }
2926 assert_eq!(store_files(&destination), 3);
2927 }
2928
2929 #[test]
2930 fn native_asset_case_variant_of_an_owned_sidecar_is_not_owned() {
2931 let temp = tempfile::tempdir().unwrap();
2932 let destination = temp.path().join("extensions");
2933 let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
2934 let library = temp.path().join("libdemo.so");
2935 std::fs::write(&library, b"library").unwrap();
2936 let first = [("Runtime/main.mjs", &b"worker v1"[..])];
2937 install_native_package_with_assets_at(
2938 &destination,
2939 &metadata,
2940 prebuilt_record("demo", "1.0.0", &first),
2941 std::slice::from_ref(&library),
2942 &sidecars(&first),
2943 false,
2944 )
2945 .unwrap();
2946 std::fs::create_dir_all(destination.join("runtime")).unwrap();
2947 std::fs::write(destination.join("runtime/main.mjs"), b"user file").unwrap();
2948 if std::fs::read(destination.join("Runtime/main.mjs")).unwrap() == b"user file" {
2949 return; }
2951
2952 let second = [("runtime/main.mjs", &b"worker v2"[..])];
2953 let error = install_native_package_with_assets_at(
2954 &destination,
2955 &metadata,
2956 prebuilt_record("demo", "1.1.0", &second),
2957 std::slice::from_ref(&library),
2958 &sidecars(&second),
2959 false,
2960 )
2961 .unwrap_err();
2962 assert!(error.contains("--force"), "{error}");
2963 assert_eq!(
2964 std::fs::read(destination.join("runtime/main.mjs")).unwrap(),
2965 b"user file"
2966 );
2967 assert_eq!(
2968 std::fs::read(destination.join("Runtime/main.mjs")).unwrap(),
2969 b"worker v1"
2970 );
2971
2972 install_native_package_with_assets_at(
2973 &destination,
2974 &metadata,
2975 prebuilt_record("demo", "1.1.0", &second),
2976 std::slice::from_ref(&library),
2977 &sidecars(&second),
2978 true,
2979 )
2980 .unwrap();
2981 assert_eq!(
2982 std::fs::read(destination.join("runtime/main.mjs")).unwrap(),
2983 b"worker v2"
2984 );
2985 assert!(!destination.join("Runtime").exists());
2986 }
2987
2988 #[test]
2989 fn native_asset_failed_first_install_removes_the_directories_it_created() {
2990 let temp = tempfile::tempdir().unwrap();
2991 let destination = temp.path().join("extensions");
2992 let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
2993 let library = temp.path().join("libdemo.so");
2994 std::fs::write(&library, b"library").unwrap();
2995 std::fs::create_dir_all(destination.join("keep")).unwrap();
2996 let assets = [("runtime/deep/x.mjs", &b"x"[..]), ("keep/y.mjs", &b"y"[..])];
2997 let error = install_native_package_at_with_hook(
2999 &destination,
3000 &metadata,
3001 prebuilt_record("demo", "1.0.0", &assets),
3002 std::slice::from_ref(&library),
3003 &sidecars(&assets),
3004 false,
3005 |index, _| {
3006 if index == 2 {
3007 Err("injected sidecar activation failure".into())
3008 } else {
3009 Ok(())
3010 }
3011 },
3012 )
3013 .unwrap_err();
3014
3015 assert!(error.contains("injected"), "{error}");
3016 assert!(!destination.join("runtime").exists());
3017 assert!(destination.join("keep").is_dir());
3019 assert!(!destination.join("libdemo.so").exists());
3020 assert!(!metadata.exists());
3021 assert_no_native_transaction_files(temp.path());
3022 assert_no_native_transaction_files(&destination);
3023 }
3024
3025 #[test]
3026 fn native_asset_overlap_within_one_package_is_rejected_before_any_write() {
3027 let temp = tempfile::tempdir().unwrap();
3029 let destination = temp.path().join("extensions");
3030 let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
3031 let library = temp.path().join("libdemo.so");
3032 std::fs::write(&library, b"demo library").unwrap();
3033 let assets = [("runtime", &b"file"[..]), ("runtime/own.mjs", &b"own"[..])];
3034
3035 let error = install_native_package_with_assets_at(
3036 &destination,
3037 &metadata,
3038 prebuilt_record("demo", "1.0.0", &assets),
3039 std::slice::from_ref(&library),
3040 &sidecars(&assets),
3041 false,
3042 )
3043 .unwrap_err();
3044 assert!(error.contains("duplicate or overlapping"), "{error}");
3045 assert_eq!(std::fs::read_dir(&destination).unwrap().count(), 0);
3046 assert!(!metadata.exists());
3047 }
3048
3049 #[cfg(unix)]
3050 #[test]
3051 fn native_asset_parent_links_are_refused_without_writing_through_them() {
3052 let temp = tempfile::tempdir().unwrap();
3053 let destination = temp.path().join("extensions");
3054 let outside = temp.path().join("outside");
3055 let metadata = temp.path().join(NATIVE_PACKAGES_FILE);
3056 let library = temp.path().join("libdemo.so");
3057 std::fs::write(&library, b"demo library").unwrap();
3058 std::fs::create_dir_all(&destination).unwrap();
3059 std::fs::create_dir_all(&outside).unwrap();
3060 std::os::unix::fs::symlink(&outside, destination.join("runtime")).unwrap();
3061 let assets = [("runtime/main.mjs", &b"demo"[..])];
3062
3063 let error = install_native_package_with_assets_at(
3064 &destination,
3065 &metadata,
3066 prebuilt_record("demo", "1.0.0", &assets),
3067 std::slice::from_ref(&library),
3068 &sidecars(&assets),
3069 false,
3070 )
3071 .unwrap_err();
3072 assert!(error.contains("is not a plain directory"), "{error}");
3073 assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 0);
3074 assert!(!destination.join("libdemo.so").exists());
3075 }
3076}