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