1use std::collections::HashSet;
11use std::io::Write;
12use std::path::{Path, PathBuf};
13use std::process::{Command, Stdio};
14
15use serde::{Deserialize, Serialize};
16
17const INSTALLER_MANIFEST: &str = "rpi-extension-installer";
18const NATIVE_PACKAGES_FILE: &str = "native-packages.json";
19const NATIVE_PACKAGES_LOCK_FILE: &str = "native-packages.lock";
20const CDYLIB_EXTENSION_HINT: &str = "hint: the crate must declare `crate-type = [\"cdylib\"]` and export `rpi_plugin_register_v2` (legacy `rpi_plugin_register` remains supported for ABI v1 plugins)";
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28pub struct InstalledNativePackage {
29 pub name: String,
30 pub version: String,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub source: Option<String>,
33 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub artifacts: Vec<String>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum NativePackageRegistry {
47 Missing,
49 Valid(Vec<InstalledNativePackage>),
51}
52
53impl NativePackageRegistry {
54 fn into_records(self) -> Vec<InstalledNativePackage> {
55 match self {
56 Self::Missing => Vec::new(),
57 Self::Valid(records) => records,
58 }
59 }
60}
61
62struct NativePackageMutationLock {
63 file: Option<std::fs::File>,
64}
65
66impl NativePackageMutationLock {
67 fn acquire(metadata_path: &Path) -> Result<Self, String> {
68 let parent = metadata_path
69 .parent()
70 .ok_or_else(|| "native package metadata has no parent".to_string())?;
71 std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
72 let lock_path = parent.join(NATIVE_PACKAGES_LOCK_FILE);
73 let file = std::fs::OpenOptions::new()
74 .create(true)
75 .read(true)
76 .write(true)
77 .open(&lock_path)
78 .map_err(|error| {
79 format!(
80 "could not open native package lock {}: {error}",
81 lock_path.display()
82 )
83 })?;
84 fs2::FileExt::try_lock_exclusive(&file).map_err(|error| {
85 format!("could not lock native package state (another install may be running): {error}")
86 })?;
87 Ok(Self { file: Some(file) })
88 }
89}
90
91impl Drop for NativePackageMutationLock {
92 fn drop(&mut self) {
93 if let Some(file) = self.file.take() {
94 let _ = fs2::FileExt::unlock(&file);
95 }
96 }
97}
98
99#[derive(Debug, Clone)]
100struct InstallOptions {
101 package: String,
102 version: Option<String>,
103 path: Option<PathBuf>,
104 locked: bool,
105 force: bool,
106}
107
108#[derive(Debug, Deserialize)]
109struct CargoMetadata {
110 packages: Vec<CargoPackage>,
111}
112
113#[derive(Debug, Deserialize)]
114struct CargoPackage {
115 name: String,
116 version: String,
117 targets: Vec<CargoTarget>,
118}
119
120#[derive(Debug, Deserialize)]
121struct CargoTarget {
122 name: String,
123 crate_types: Vec<String>,
124}
125
126pub fn run(args: &[String]) -> i32 {
130 if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") {
131 print_help();
132 return 0;
133 }
134 let options = match parse_args(args) {
135 Ok(options) => options,
136 Err(message) => {
137 eprintln!("error: {message}");
138 print_help();
139 return 2;
140 }
141 };
142
143 if let Err(error) = read_native_package_registry() {
147 eprintln!("error: cannot safely install while native package metadata is invalid: {error}");
148 return 1;
149 }
150
151 let temp = match tempfile::tempdir() {
152 Ok(temp) => temp,
153 Err(error) => {
154 eprintln!("error: could not create a temporary Cargo workspace: {error}");
155 return 1;
156 }
157 };
158 let manifest = temp.path().join("Cargo.toml");
159 if let Err(error) = write_manifest(&manifest, &options) {
160 eprintln!("error: could not prepare Cargo workspace: {error}");
161 return 1;
162 }
163
164 if let Err(error) = cargo_command("fetch", &manifest, &options, false) {
165 eprintln!("error: could not resolve `{}`: {error}", options.package);
166 return 1;
167 }
168
169 let metadata = match cargo_metadata(&manifest, &options) {
170 Ok(metadata) => metadata,
171 Err(error) => {
172 eprintln!("error: could not inspect `{}`: {error}", options.package);
173 return 1;
174 }
175 };
176 let package = match metadata
177 .packages
178 .iter()
179 .find(|package| package.name == options.package)
180 {
181 Some(package) => package,
182 None => {
183 eprintln!(
184 "error: Cargo did not resolve a package named `{}`",
185 options.package
186 );
187 return 1;
188 }
189 };
190 let cdylib_targets: Vec<&CargoTarget> = package
191 .targets
192 .iter()
193 .filter(|target| target.crate_types.iter().any(|kind| kind == "cdylib"))
194 .collect();
195 if cdylib_targets.is_empty() {
196 eprintln!(
197 "error: `{}` is not an rpi extension crate; it has no `cdylib` target",
198 options.package
199 );
200 eprintln!("{CDYLIB_EXTENSION_HINT}");
201 return 1;
202 }
203
204 if let Err(error) = cargo_command("build", &manifest, &options, true) {
205 eprintln!("error: failed to build `{}`: {error}", options.package);
206 return 1;
207 }
208
209 let artifact_dir = temp.path().join("target").join("release");
210 let artifacts = match find_artifacts(&artifact_dir, &cdylib_targets) {
211 Ok(artifacts) => artifacts,
212 Err(error) => {
213 eprintln!("error: {error}");
214 return 1;
215 }
216 };
217
218 let agent_dir = match crate::config::agent_dir() {
219 Ok(dir) => dir,
220 Err(error) => {
221 eprintln!("error: could not resolve the rpi config directory: {error}");
222 return 1;
223 }
224 };
225 let destination = agent_dir.join("extensions");
226 if let Err(error) = std::fs::create_dir_all(&destination) {
227 eprintln!(
228 "error: could not create extension directory {}: {error}",
229 destination.display()
230 );
231 return 1;
232 }
233
234 let version = metadata
235 .packages
236 .iter()
237 .find(|candidate| candidate.name == options.package)
238 .map(|candidate| candidate.version.clone())
239 .unwrap_or_else(|| "0.0.0".to_string());
240 let record = InstalledNativePackage {
241 name: options.package.clone(),
242 version,
243 source: options
244 .path
245 .as_ref()
246 .map(|path| path.to_string_lossy().into_owned()),
247 artifacts: Vec::new(),
248 };
249 let installed = match install_native_package_at(
250 &destination,
251 &agent_dir.join(NATIVE_PACKAGES_FILE),
252 record,
253 &artifacts,
254 options.force,
255 ) {
256 Ok(installed) => installed,
257 Err(error) => {
258 eprintln!("error: could not install `{}`: {error}", options.package);
259 return 1;
260 }
261 };
262 for target in installed {
263 println!("installed {}", target.display());
264 }
265 println!("rpi will load this extension on the next start.");
266 0
267}
268
269pub fn uninstall(args: &[String]) -> i32 {
275 if args.len() == 1 && matches!(args[0].as_str(), "--help" | "-h") {
276 print_uninstall_help();
277 return 0;
278 }
279 let name = match parse_uninstall_name(args) {
280 Ok(name) => name,
281 Err(error) => {
282 eprintln!("error: {error}");
283 print_uninstall_help();
284 return 2;
285 }
286 };
287 let agent = match crate::config::agent_dir() {
288 Ok(path) => path,
289 Err(error) => {
290 eprintln!("error: could not resolve the rpi config directory: {error}");
291 return 1;
292 }
293 };
294 let extension_dir = agent.join("extensions");
295 let metadata_path = agent.join(NATIVE_PACKAGES_FILE);
296 let _lock = match NativePackageMutationLock::acquire(&metadata_path) {
297 Ok(lock) => lock,
298 Err(error) => {
299 eprintln!("error: cannot safely uninstall: {error}");
300 return 1;
301 }
302 };
303 let records = match read_native_package_registry_at(&metadata_path) {
304 Ok(registry) => registry.into_records(),
305 Err(error) => {
306 eprintln!(
307 "error: cannot safely uninstall while native package metadata is invalid: {error}"
308 );
309 return 1;
310 }
311 };
312 let had_record = records.iter().any(|record| record.name == name);
313 let wanted = normalize_name(&name);
314 let artifact_names: std::collections::HashSet<String> = records
315 .iter()
316 .filter(|record| record.name == name)
317 .flat_map(|record| record.artifacts.iter().cloned())
318 .collect();
319 let use_legacy_name_fallback = artifact_names.is_empty();
320 let mut artifact_targets = Vec::new();
321 if let Ok(entries) = std::fs::read_dir(&extension_dir) {
322 for entry in entries.flatten() {
323 let path = entry.path();
324 let is_artifact = artifact_names.contains(
325 &path
326 .file_name()
327 .map(|name| name.to_string_lossy().into_owned())
328 .unwrap_or_default(),
329 ) || (use_legacy_name_fallback
330 && path
331 .file_stem()
332 .and_then(|stem| stem.to_str())
333 .map(|stem| normalize_name(stem.trim_start_matches("lib")) == wanted)
334 .unwrap_or(false));
335 if is_artifact && is_dynamic_library(&path) {
336 artifact_targets.push(path);
337 }
338 }
339 }
340 let mut remaining: Vec<_> = records
341 .into_iter()
342 .filter(|record| record.name != name)
343 .collect();
344 if had_record {
345 remaining.sort_by(|left, right| left.name.cmp(&right.name));
346 }
347 let mut replacements = Vec::new();
348 let mut removals = artifact_targets.clone();
349 if had_record {
350 if remaining.is_empty() {
351 removals.push(metadata_path.clone());
352 } else {
353 let registry = match serde_json::to_vec_pretty(&remaining) {
354 Ok(registry) => registry,
355 Err(error) => {
356 eprintln!("error: could not serialize native package metadata: {error}");
357 return 1;
358 }
359 };
360 let staged = match stage_native_bytes(®istry, &metadata_path) {
361 Ok(staged) => staged,
362 Err(error) => {
363 eprintln!("error: could not stage native package metadata: {error}");
364 return 1;
365 }
366 };
367 replacements.push(NativeReplacement {
368 staged,
369 target: metadata_path.clone(),
370 });
371 }
372 }
373 if let Err(error) = activate_native_transaction(&replacements, &removals, &mut |_, _| Ok(())) {
374 eprintln!("error: could not uninstall Rust extension {name}: {error}");
375 return 1;
376 }
377 for path in &artifact_targets {
378 println!("removed {}", path.display());
379 }
380 let removed = artifact_targets.len();
381 if removed == 0 && !had_record {
382 println!("Rust extension is not installed: {name}");
383 return 0;
384 }
385 println!("uninstalled Rust extension {name}");
386 0
387}
388
389fn parse_uninstall_name(args: &[String]) -> Result<String, String> {
390 let mut name = None;
391 for arg in args {
392 match arg.as_str() {
393 "--help" | "-h" => return Err("use `rpi uninstall --help` for usage".into()),
394 value if value.starts_with('-') => {
395 return Err(format!("unknown uninstall option `{value}`"))
396 }
397 value => {
398 if name.replace(value.to_string()).is_some() {
399 return Err("uninstall accepts exactly one crate name".into());
400 }
401 }
402 }
403 }
404 let name = name.ok_or_else(|| "missing crate name".to_string())?;
405 if !valid_package_name(&name) {
406 return Err(format!("invalid Cargo package name `{name}`"));
407 }
408 Ok(name)
409}
410
411#[cfg(test)]
412fn write_native_packages_at(path: &Path, records: &[InstalledNativePackage]) -> Result<(), String> {
413 if records.is_empty() {
414 match std::fs::remove_file(path) {
415 Ok(()) => return Ok(()),
416 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
417 Err(error) => return Err(error.to_string()),
418 }
419 }
420 let parent = path
421 .parent()
422 .ok_or_else(|| "native package metadata has no parent".to_string())?;
423 std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
424 let data = serde_json::to_vec_pretty(records).map_err(|error| error.to_string())?;
425 std::fs::write(path, data).map_err(|error| error.to_string())
426}
427
428pub fn read_native_package_registry() -> Result<NativePackageRegistry, String> {
430 let path = crate::config::agent_dir()
431 .map_err(|error| error.to_string())?
432 .join(NATIVE_PACKAGES_FILE);
433 read_native_package_registry_at(&path)
434}
435
436fn read_native_package_registry_at(path: &Path) -> Result<NativePackageRegistry, String> {
437 let data = match std::fs::read(path) {
438 Ok(data) => data,
439 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
440 return Ok(NativePackageRegistry::Missing)
441 }
442 Err(error) => {
443 return Err(format!("could not read {}: {error}", path.display()));
444 }
445 };
446 serde_json::from_slice(&data)
447 .map(NativePackageRegistry::Valid)
448 .map_err(|error| format!("invalid JSON or schema in {}: {error}", path.display()))
449}
450
451pub fn installed_native_packages() -> Vec<InstalledNativePackage> {
457 installed_native_packages_strict().unwrap_or_default()
458}
459
460pub(crate) fn installed_native_packages_strict() -> Result<Vec<InstalledNativePackage>, String> {
464 read_native_package_registry().map(NativePackageRegistry::into_records)
465}
466
467#[cfg(test)]
468fn record_native_package_at(path: &Path, record: &InstalledNativePackage) -> Result<(), String> {
469 let mut records = read_native_package_registry_at(path)?.into_records();
470 if let Some(existing) = records.iter_mut().find(|item| item.name == record.name) {
471 *existing = record.clone();
472 } else {
473 records.push(record.clone());
474 }
475 records.sort_by(|left, right| left.name.cmp(&right.name));
476 write_native_packages_at(path, &records)
477}
478
479struct StagedNativeFile {
480 path: PathBuf,
481}
482
483impl Drop for StagedNativeFile {
484 fn drop(&mut self) {
485 let _ = std::fs::remove_file(&self.path);
486 }
487}
488
489struct NativeReplacement {
490 staged: StagedNativeFile,
491 target: PathBuf,
492}
493
494struct NativeBackup {
495 target: PathBuf,
496 backup: PathBuf,
497}
498
499fn install_native_package_at(
500 destination: &Path,
501 metadata_path: &Path,
502 record: InstalledNativePackage,
503 artifacts: &[PathBuf],
504 force: bool,
505) -> Result<Vec<PathBuf>, String> {
506 install_native_package_at_with_hook(
507 destination,
508 metadata_path,
509 record,
510 artifacts,
511 force,
512 |_, _| Ok(()),
513 )
514}
515
516fn install_native_package_at_with_hook(
517 destination: &Path,
518 metadata_path: &Path,
519 mut record: InstalledNativePackage,
520 artifacts: &[PathBuf],
521 force: bool,
522 mut before_activate: impl FnMut(usize, &Path) -> Result<(), String>,
523) -> Result<Vec<PathBuf>, String> {
524 std::fs::create_dir_all(destination).map_err(|error| {
525 format!(
526 "could not create extension directory {}: {error}",
527 destination.display()
528 )
529 })?;
530 let metadata_parent = metadata_path
531 .parent()
532 .ok_or_else(|| "native package metadata has no parent".to_string())?;
533 std::fs::create_dir_all(metadata_parent).map_err(|error| error.to_string())?;
534 let _lock = NativePackageMutationLock::acquire(metadata_path)?;
535 let mut records = read_native_package_registry_at(metadata_path)?.into_records();
536 let previous = records
537 .iter()
538 .find(|candidate| candidate.name == record.name)
539 .cloned();
540
541 let mut new_artifacts = Vec::with_capacity(artifacts.len());
542 let mut new_keys = HashSet::new();
543 for artifact in artifacts {
544 let metadata = std::fs::symlink_metadata(artifact).map_err(|error| {
545 format!(
546 "could not inspect built artifact {}: {error}",
547 artifact.display()
548 )
549 })?;
550 if !metadata.is_file() || metadata.file_type().is_symlink() {
551 return Err(format!(
552 "built artifact is not a regular file: {}",
553 artifact.display()
554 ));
555 }
556 let name = artifact_file_name(artifact)?;
557 let key = native_artifact_identity(&name);
558 if !new_keys.insert(key) {
559 return Err(format!("duplicate built artifact name `{name}`"));
560 }
561 new_artifacts.push((name, artifact.clone()));
562 }
563 if new_artifacts.is_empty() {
564 return Err("Cargo produced no installable cdylib artifacts".to_string());
565 }
566
567 for other in records
568 .iter()
569 .filter(|candidate| candidate.name != record.name)
570 {
571 for owned in &other.artifacts {
572 checked_registered_artifact_path(destination, owned)?;
573 if new_keys.contains(&native_artifact_identity(owned)) {
574 return Err(format!(
575 "artifact `{owned}` is already owned by installed package `{}`",
576 other.name
577 ));
578 }
579 }
580 }
581
582 let mut stale_targets = Vec::new();
583 if let Some(previous) = &previous {
584 if previous.artifacts.is_empty() {
585 let wanted = normalize_name(&record.name);
586 for entry in std::fs::read_dir(destination).map_err(|error| {
587 format!(
588 "could not inspect extension directory {}: {error}",
589 destination.display()
590 )
591 })? {
592 let entry = entry.map_err(|error| {
593 format!("could not inspect installed extension artifact: {error}")
594 })?;
595 let path = entry.path();
596 let matches_legacy_name = path
597 .file_stem()
598 .and_then(|stem| stem.to_str())
599 .map(|stem| normalize_name(stem.trim_start_matches("lib")) == wanted)
600 .unwrap_or(false);
601 if matches_legacy_name && is_dynamic_library(&path) {
602 stale_targets.push(path);
603 }
604 }
605 } else {
606 for old_name in &previous.artifacts {
607 if !new_keys.contains(&native_artifact_identity(old_name)) {
608 stale_targets.push(checked_registered_artifact_path(destination, old_name)?);
609 }
610 }
611 }
612 }
613
614 let installed_targets = new_artifacts
615 .iter()
616 .map(|(name, _)| destination.join(name))
617 .collect::<Vec<_>>();
618 if !force {
619 for target in &installed_targets {
620 match std::fs::symlink_metadata(target) {
621 Ok(_) => {
622 return Err(format!(
623 "extension {} already exists; use --force to replace it",
624 target.display()
625 ))
626 }
627 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
628 Err(error) => {
629 return Err(format!(
630 "could not inspect extension target {}: {error}",
631 target.display()
632 ))
633 }
634 }
635 }
636 }
637
638 record.artifacts = new_artifacts.iter().map(|(name, _)| name.clone()).collect();
639 if let Some(existing) = records
640 .iter_mut()
641 .find(|candidate| candidate.name == record.name)
642 {
643 *existing = record;
644 } else {
645 records.push(record);
646 }
647 records.sort_by(|left, right| left.name.cmp(&right.name));
648
649 let mut replacements = Vec::with_capacity(new_artifacts.len() + 1);
650 for ((_, source), target) in new_artifacts.iter().zip(&installed_targets) {
651 replacements.push(NativeReplacement {
652 staged: stage_native_artifact(source, target)?,
653 target: target.clone(),
654 });
655 }
656 let registry = serde_json::to_vec_pretty(&records).map_err(|error| error.to_string())?;
657 replacements.push(NativeReplacement {
658 staged: stage_native_bytes(®istry, metadata_path)?,
659 target: metadata_path.to_path_buf(),
660 });
661
662 activate_native_transaction(&replacements, &stale_targets, &mut before_activate)?;
663 Ok(installed_targets)
664}
665
666fn artifact_file_name(path: &Path) -> Result<String, String> {
667 let name = path
668 .file_name()
669 .and_then(|name| name.to_str())
670 .ok_or_else(|| format!("artifact has no UTF-8 file name: {}", path.display()))?;
671 checked_registered_artifact_path(Path::new("."), name)?;
672 Ok(name.to_string())
673}
674
675fn checked_registered_artifact_path(destination: &Path, name: &str) -> Result<PathBuf, String> {
676 let relative = Path::new(name);
677 let is_single_component = relative.components().count() == 1
678 && relative.file_name().and_then(|value| value.to_str()) == Some(name);
679 if !is_single_component || !is_dynamic_library(relative) {
680 return Err(format!("unsafe native package artifact name `{name}`"));
681 }
682 Ok(destination.join(relative))
683}
684
685fn native_artifact_identity(name: &str) -> String {
686 if cfg!(windows) {
687 name.to_ascii_lowercase()
688 } else {
689 name.to_string()
690 }
691}
692
693fn transaction_path_identity(path: &Path) -> String {
694 let value = path.to_string_lossy().into_owned();
695 if cfg!(windows) {
696 value.to_ascii_lowercase()
697 } else {
698 value
699 }
700}
701
702fn adjacent_transaction_path(target: &Path, kind: &str) -> Result<PathBuf, String> {
703 let parent = target
704 .parent()
705 .ok_or_else(|| format!("transaction target has no parent: {}", target.display()))?;
706 let leaf = target
707 .file_name()
708 .and_then(|name| name.to_str())
709 .unwrap_or("artifact");
710 for _ in 0..8 {
711 let candidate = parent.join(format!(
712 ".{leaf}.rpi-{kind}-{}",
713 uuid::Uuid::new_v4().simple()
714 ));
715 match std::fs::symlink_metadata(&candidate) {
716 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(candidate),
717 Ok(_) => {}
718 Err(error) => {
719 return Err(format!(
720 "could not inspect transaction path {}: {error}",
721 candidate.display()
722 ))
723 }
724 }
725 }
726 Err(format!(
727 "could not allocate a transaction path beside {}",
728 target.display()
729 ))
730}
731
732fn stage_native_artifact(source: &Path, target: &Path) -> Result<StagedNativeFile, String> {
733 let stage = adjacent_transaction_path(target, "stage")?;
734 let result = (|| {
735 let source_metadata = std::fs::symlink_metadata(source).map_err(|error| {
736 format!(
737 "could not inspect built artifact {}: {error}",
738 source.display()
739 )
740 })?;
741 let mut input = std::fs::File::open(source).map_err(|error| {
742 format!(
743 "could not open built artifact {}: {error}",
744 source.display()
745 )
746 })?;
747 let mut output = std::fs::OpenOptions::new()
748 .write(true)
749 .create_new(true)
750 .open(&stage)
751 .map_err(|error| {
752 format!(
753 "could not create staged artifact {}: {error}",
754 stage.display()
755 )
756 })?;
757 std::io::copy(&mut input, &mut output)
758 .and_then(|_| output.sync_all())
759 .map_err(|error| {
760 format!(
761 "could not write staged artifact {} beside {}: {error}",
762 stage.display(),
763 target.display()
764 )
765 })?;
766 drop(output);
767 std::fs::set_permissions(&stage, source_metadata.permissions()).map_err(|error| {
768 format!(
769 "could not preserve permissions on staged artifact {}: {error}",
770 stage.display()
771 )
772 })
773 })();
774 if let Err(error) = result {
775 let _ = std::fs::remove_file(&stage);
776 return Err(error);
777 }
778 Ok(StagedNativeFile { path: stage })
779}
780fn stage_native_bytes(bytes: &[u8], target: &Path) -> Result<StagedNativeFile, String> {
781 let stage = adjacent_transaction_path(target, "stage")?;
782 let result = (|| {
783 let mut file = std::fs::OpenOptions::new()
784 .write(true)
785 .create_new(true)
786 .open(&stage)
787 .map_err(|error| {
788 format!(
789 "could not create staged registry {}: {error}",
790 stage.display()
791 )
792 })?;
793 file.write_all(bytes)
794 .and_then(|()| file.sync_all())
795 .map_err(|error| {
796 format!(
797 "could not flush staged registry {}: {error}",
798 stage.display()
799 )
800 })
801 })();
802 if let Err(error) = result {
803 let _ = std::fs::remove_file(&stage);
804 return Err(error);
805 }
806 Ok(StagedNativeFile { path: stage })
807}
808
809fn activate_native_transaction(
810 replacements: &[NativeReplacement],
811 removals: &[PathBuf],
812 before_activate: &mut impl FnMut(usize, &Path) -> Result<(), String>,
813) -> Result<(), String> {
814 let mut affected = Vec::new();
815 let mut seen = HashSet::new();
816 for target in replacements
817 .iter()
818 .map(|replacement| &replacement.target)
819 .chain(removals.iter())
820 {
821 if seen.insert(transaction_path_identity(target)) {
822 affected.push(target.clone());
823 }
824 }
825
826 for target in &affected {
827 match std::fs::symlink_metadata(target) {
828 Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {}
829 Ok(_) => {
830 return Err(format!(
831 "refusing to replace non-regular native package file {}",
832 target.display()
833 ))
834 }
835 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
836 Err(error) => {
837 return Err(format!(
838 "could not inspect native package file {}: {error}",
839 target.display()
840 ))
841 }
842 }
843 }
844
845 let mut backups = Vec::new();
846 for target in &affected {
847 if !target.exists() {
848 continue;
849 }
850 let backup = match adjacent_transaction_path(target, "backup") {
851 Ok(backup) => backup,
852 Err(error) => {
853 let rollback = rollback_native_transaction(&[], &backups);
854 return Err(transaction_error(error, rollback));
855 }
856 };
857 if let Err(error) = std::fs::rename(target, &backup) {
858 let rollback = rollback_native_transaction(&[], &backups);
859 return Err(transaction_error(
860 format!("could not back up {}: {error}", target.display()),
861 rollback,
862 ));
863 }
864 backups.push(NativeBackup {
865 target: target.clone(),
866 backup,
867 });
868 }
869
870 let mut activated = Vec::new();
871 for (index, replacement) in replacements.iter().enumerate() {
872 let activation = before_activate(index, &replacement.target).and_then(|()| {
873 std::fs::rename(&replacement.staged.path, &replacement.target).map_err(|error| {
874 format!(
875 "could not activate replacement {}: {error}",
876 replacement.target.display()
877 )
878 })
879 });
880 if let Err(error) = activation {
881 let rollback = rollback_native_transaction(&activated, &backups);
882 return Err(transaction_error(error, rollback));
883 }
884 activated.push(replacement.target.clone());
885 }
886
887 for backup in backups {
888 if let Err(error) = std::fs::remove_file(&backup.backup) {
889 eprintln!(
890 "warning: native package updated but backup {} could not be removed: {error}",
891 backup.backup.display()
892 );
893 }
894 }
895 Ok(())
896}
897
898fn rollback_native_transaction(
899 activated: &[PathBuf],
900 backups: &[NativeBackup],
901) -> Result<(), String> {
902 let mut errors = Vec::new();
903 for target in activated.iter().rev() {
904 match std::fs::remove_file(target) {
905 Ok(()) => {}
906 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
907 Err(error) => errors.push(format!("could not remove {}: {error}", target.display())),
908 }
909 }
910 for backup in backups.iter().rev() {
911 if let Err(error) = std::fs::rename(&backup.backup, &backup.target) {
912 errors.push(format!(
913 "could not restore {} from {}: {error}",
914 backup.target.display(),
915 backup.backup.display()
916 ));
917 }
918 }
919 if errors.is_empty() {
920 Ok(())
921 } else {
922 Err(errors.join("; "))
923 }
924}
925
926fn transaction_error(error: String, rollback: Result<(), String>) -> String {
927 match rollback {
928 Ok(()) => error,
929 Err(rollback) => format!("{error}; rollback also failed: {rollback}"),
930 }
931}
932
933fn parse_args(args: &[String]) -> Result<InstallOptions, String> {
934 let mut package = None;
935 let mut version = None;
936 let mut path = None;
937 let mut locked = false;
938 let mut force = false;
939 let mut i = 0;
940 while i < args.len() {
941 match args[i].as_str() {
942 "--help" | "-h" => return Err(help_requested().to_string()),
943 "--locked" => locked = true,
944 "--force" | "-f" => force = true,
945 "--version" | "-V" => {
946 i += 1;
947 version = Some(value(args, i, "--version")?);
948 }
949 "--path" => {
950 i += 1;
951 path = Some(PathBuf::from(value(args, i, "--path")?));
952 }
953 value if value.starts_with('-') => {
954 return Err(format!("unknown install option `{value}`"));
955 }
956 value => {
957 if package.replace(value.to_string()).is_some() {
958 return Err("install accepts exactly one crate name".to_string());
959 }
960 }
961 }
962 i += 1;
963 }
964 let package = package.ok_or_else(|| "missing crate name".to_string())?;
965 if path.is_some() && version.is_some() {
966 return Err("--path and --version cannot be used together".to_string());
967 }
968 if !valid_package_name(&package) {
969 return Err(format!("invalid Cargo package name `{package}`"));
970 }
971 Ok(InstallOptions {
972 package,
973 version,
974 path,
975 locked,
976 force,
977 })
978}
979
980fn value(args: &[String], index: usize, flag: &str) -> Result<String, String> {
981 args.get(index)
982 .filter(|value| !value.starts_with('-'))
983 .cloned()
984 .ok_or_else(|| format!("{flag} requires a value"))
985}
986
987fn valid_package_name(name: &str) -> bool {
988 !name.is_empty()
989 && name
990 .bytes()
991 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
992}
993
994fn help_requested() -> &'static str {
995 "use `rpi install --help` for usage"
996}
997
998pub fn print_help() {
999 println!(
1000 "Usage: rpi install <crate> [options]\n\nInstall an rpi Rust cdylib extension from crates.io.\n\nOptions:\n --version <version> Install a specific crates.io version\n --path <directory> Build a local extension crate\n --locked Require Cargo.lock to remain unchanged\n --force, -f Replace an existing installed extension\n --help, -h Show this help\n\nExamples:\n rpi install rpi-extension-example\n rpi install rpi-extension-example --version 0.1.0\n rpi install my-extension --path ../my-rpi-extension --force"
1001 );
1002}
1003
1004fn print_uninstall_help() {
1005 println!(
1006 "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"
1007 );
1008}
1009
1010fn write_manifest(path: &Path, options: &InstallOptions) -> Result<(), String> {
1011 let source_dir = path
1012 .parent()
1013 .ok_or_else(|| "temporary workspace has no parent directory".to_string())?
1014 .join("src");
1015 std::fs::create_dir_all(&source_dir).map_err(|error| error.to_string())?;
1016 std::fs::write(source_dir.join("lib.rs"), "pub fn installer_marker() {}\n")
1019 .map_err(|error| error.to_string())?;
1020 let dependency = if let Some(local_path) = &options.path {
1021 let absolute = if local_path.is_absolute() {
1022 local_path.clone()
1023 } else {
1024 std::env::current_dir()
1025 .map_err(|error| error.to_string())?
1026 .join(local_path)
1027 };
1028 format!(
1029 "rpi_extension_dep = {{ package = {:?}, path = {:?} }}",
1030 options.package,
1031 absolute.display().to_string()
1032 )
1033 } else {
1034 let version = options.version.as_deref().unwrap_or("*");
1035 format!(
1036 "rpi_extension_dep = {{ package = {:?}, version = {:?} }}",
1037 options.package, version
1038 )
1039 };
1040 let contents = format!(
1041 "[package]\nname = \"{INSTALLER_MANIFEST}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[workspace]\n\n[dependencies]\n{dependency}\n"
1042 );
1043 std::fs::write(path, contents).map_err(|error| error.to_string())
1044}
1045
1046fn cargo_command(
1047 subcommand: &str,
1048 manifest: &Path,
1049 options: &InstallOptions,
1050 build: bool,
1051) -> Result<(), String> {
1052 let mut command = Command::new("cargo");
1053 command.arg(subcommand).arg("--manifest-path").arg(manifest);
1054 if build {
1055 command
1056 .arg("--package")
1057 .arg(&options.package)
1058 .arg("--release")
1059 .arg("--target-dir")
1060 .arg(manifest.parent().unwrap().join("target"));
1061 }
1062 if options.locked {
1063 command.arg("--locked");
1064 }
1065 let status = command
1066 .stdin(Stdio::inherit())
1067 .stdout(Stdio::inherit())
1068 .stderr(Stdio::inherit())
1069 .status()
1070 .map_err(|error| format!("could not execute cargo: {error}"))?;
1071 if status.success() {
1072 Ok(())
1073 } else {
1074 Err(format!("cargo {subcommand} exited with {status}"))
1075 }
1076}
1077
1078fn cargo_metadata(manifest: &Path, options: &InstallOptions) -> Result<CargoMetadata, String> {
1079 let mut command = Command::new("cargo");
1080 command
1081 .arg("metadata")
1082 .arg("--format-version")
1083 .arg("1")
1084 .arg("--manifest-path")
1085 .arg(manifest);
1086 if options.locked {
1087 command.arg("--locked");
1088 }
1089 let output = command
1090 .output()
1091 .map_err(|error| format!("could not execute cargo: {error}"))?;
1092 if !output.status.success() {
1093 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
1094 }
1095 serde_json::from_slice(&output.stdout)
1096 .map_err(|error| format!("invalid cargo metadata: {error}"))
1097}
1098
1099fn find_artifacts(release_dir: &Path, targets: &[&CargoTarget]) -> Result<Vec<PathBuf>, String> {
1100 let mut artifacts = Vec::new();
1101 for target in targets {
1102 let wanted = normalize_name(&target.name);
1103 let mut matches = Vec::new();
1104 for dir in [release_dir.to_path_buf(), release_dir.join("deps")] {
1105 let entries = std::fs::read_dir(&dir).map_err(|error| {
1106 format!("could not inspect build output {}: {error}", dir.display())
1107 })?;
1108 for entry in entries.flatten() {
1109 let path = entry.path();
1110 if !is_dynamic_library(&path) {
1111 continue;
1112 }
1113 let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
1114 continue;
1115 };
1116 let normalized = normalize_name(stem.trim_start_matches("lib"));
1117 if normalized == wanted {
1118 matches.push(path);
1119 }
1120 }
1121 }
1122 matches.sort_by_key(|path| path.components().count());
1123 let artifact = matches.into_iter().next().ok_or_else(|| {
1124 format!(
1125 "Cargo built `{}` but no cdylib artifact was found in {}",
1126 target.name,
1127 release_dir.display()
1128 )
1129 })?;
1130 artifacts.push(artifact);
1131 }
1132 Ok(artifacts)
1133}
1134
1135fn normalize_name(name: &str) -> String {
1136 name.replace('-', "_").to_ascii_lowercase()
1137}
1138
1139fn is_dynamic_library(path: &Path) -> bool {
1140 matches!(
1141 path.extension()
1142 .and_then(|extension| extension.to_str())
1143 .map(|extension| extension.to_ascii_lowercase())
1144 .as_deref(),
1145 Some("dll" | "so" | "dylib" | "pyd")
1146 )
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151 use super::*;
1152
1153 fn args(values: &[&str]) -> Vec<String> {
1154 values.iter().map(|value| value.to_string()).collect()
1155 }
1156
1157 fn native_record(name: &str) -> InstalledNativePackage {
1158 InstalledNativePackage {
1159 name: name.to_string(),
1160 version: "1.2.3".to_string(),
1161 source: None,
1162 artifacts: vec![format!("{name}.dll")],
1163 }
1164 }
1165
1166 struct TempAgent {
1167 _guard: std::sync::MutexGuard<'static, ()>,
1168 temp: tempfile::TempDir,
1169 previous: Option<std::ffi::OsString>,
1170 }
1171
1172 impl TempAgent {
1173 fn new() -> Self {
1174 let guard = crate::config::test_support::env_lock()
1175 .lock()
1176 .unwrap_or_else(|poisoned| poisoned.into_inner());
1177 let previous = std::env::var_os(crate::config::CONFIG_DIR_ENV);
1178 let temp = tempfile::tempdir().unwrap();
1179 std::env::set_var(crate::config::CONFIG_DIR_ENV, temp.path());
1180 Self {
1181 _guard: guard,
1182 temp,
1183 previous,
1184 }
1185 }
1186
1187 fn metadata_path(&self) -> PathBuf {
1188 self.temp.path().join(NATIVE_PACKAGES_FILE)
1189 }
1190 }
1191
1192 impl Drop for TempAgent {
1193 fn drop(&mut self) {
1194 match self.previous.take() {
1195 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
1196 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
1197 }
1198 }
1199 }
1200
1201 #[test]
1202 fn parses_registry_package_and_options() {
1203 let parsed = parse_args(&args(&["my-extension", "--version", "1.2.3", "--force"])).unwrap();
1204 assert_eq!(parsed.package, "my-extension");
1205 assert_eq!(parsed.version.as_deref(), Some("1.2.3"));
1206 assert!(parsed.force);
1207 }
1208
1209 #[test]
1210 fn parses_local_package() {
1211 let parsed = parse_args(&args(&["--path", "../extension", "my-extension"])).unwrap();
1212 assert_eq!(parsed.path, Some(PathBuf::from("../extension")));
1213 }
1214
1215 #[test]
1216 fn rejects_non_extension_options_and_invalid_names() {
1217 assert!(parse_args(&args(&["my.extension"])).is_err());
1218 assert!(parse_args(&args(&["my-extension", "--unknown"])).is_err());
1219 assert!(parse_args(&args(&["my-extension", "--path", ".", "--version", "1"])).is_err());
1220 }
1221
1222 #[test]
1223 fn cdylib_hint_recommends_v2_and_documents_legacy_compatibility() {
1224 assert!(CDYLIB_EXTENSION_HINT.contains("rpi_plugin_register_v2"));
1225 assert!(CDYLIB_EXTENSION_HINT.contains("legacy `rpi_plugin_register`"));
1226 assert!(CDYLIB_EXTENSION_HINT.contains("ABI v1"));
1227 }
1228
1229 #[test]
1230 fn registry_read_distinguishes_missing_and_valid_files() {
1231 let temp = tempfile::tempdir().unwrap();
1232 let path = temp.path().join(NATIVE_PACKAGES_FILE);
1233
1234 assert_eq!(
1235 read_native_package_registry_at(&path).unwrap(),
1236 NativePackageRegistry::Missing
1237 );
1238
1239 std::fs::write(
1240 &path,
1241 br#"[{"name":"demo","version":"1.2.3","source":null}]"#,
1242 )
1243 .unwrap();
1244 assert_eq!(
1245 read_native_package_registry_at(&path).unwrap(),
1246 NativePackageRegistry::Valid(vec![InstalledNativePackage {
1247 name: "demo".to_string(),
1248 version: "1.2.3".to_string(),
1249 source: None,
1250 artifacts: Vec::new(),
1251 }])
1252 );
1253 }
1254
1255 #[test]
1256 fn registry_read_rejects_corrupt_json() {
1257 let temp = tempfile::tempdir().unwrap();
1258 let path = temp.path().join(NATIVE_PACKAGES_FILE);
1259 std::fs::write(&path, b"[{").unwrap();
1260
1261 let error = read_native_package_registry_at(&path).unwrap_err();
1262 assert!(error.contains("invalid JSON or schema"));
1263 }
1264
1265 #[test]
1266 fn registry_read_rejects_wrong_schema() {
1267 let temp = tempfile::tempdir().unwrap();
1268 let path = temp.path().join(NATIVE_PACKAGES_FILE);
1269 std::fs::write(&path, br#"{"name":"demo","version":"1.2.3"}"#).unwrap();
1270
1271 let error = read_native_package_registry_at(&path).unwrap_err();
1272 assert!(error.contains("invalid JSON or schema"));
1273 }
1274
1275 #[test]
1276 fn record_does_not_replace_a_corrupt_registry() {
1277 let temp = tempfile::tempdir().unwrap();
1278 let path = temp.path().join(NATIVE_PACKAGES_FILE);
1279 let original = b"[{broken metadata";
1280 std::fs::write(&path, original).unwrap();
1281
1282 assert!(record_native_package_at(&path, &native_record("demo")).is_err());
1283 assert_eq!(std::fs::read(&path).unwrap(), original);
1284 }
1285
1286 #[test]
1287 fn native_install_transaction_replaces_artifacts_and_removes_stale_ones() {
1288 let temp = tempfile::tempdir().unwrap();
1289 let destination = temp.path().join("extensions");
1290 let metadata_path = temp.path().join(NATIVE_PACKAGES_FILE);
1291 let build = temp.path().join("build");
1292 std::fs::create_dir_all(&destination).unwrap();
1293 std::fs::create_dir_all(&build).unwrap();
1294 std::fs::write(destination.join("old.dll"), b"old-only").unwrap();
1295 std::fs::write(destination.join("same.dll"), b"old-shared").unwrap();
1296 let old = InstalledNativePackage {
1297 name: "demo".into(),
1298 version: "1.0.0".into(),
1299 source: None,
1300 artifacts: vec!["old.dll".into(), "same.dll".into()],
1301 };
1302 write_native_packages_at(&metadata_path, &[old]).unwrap();
1303 std::fs::write(build.join("new.dll"), b"new-only").unwrap();
1304 std::fs::write(build.join("same.dll"), b"new-shared").unwrap();
1305
1306 let installed = install_native_package_at(
1307 &destination,
1308 &metadata_path,
1309 InstalledNativePackage {
1310 name: "demo".into(),
1311 version: "2.0.0".into(),
1312 source: None,
1313 artifacts: Vec::new(),
1314 },
1315 &[build.join("new.dll"), build.join("same.dll")],
1316 true,
1317 )
1318 .unwrap();
1319
1320 assert_eq!(
1321 installed,
1322 vec![destination.join("new.dll"), destination.join("same.dll")]
1323 );
1324 assert!(!destination.join("old.dll").exists());
1325 assert_eq!(
1326 std::fs::read(destination.join("new.dll")).unwrap(),
1327 b"new-only"
1328 );
1329 assert_eq!(
1330 std::fs::read(destination.join("same.dll")).unwrap(),
1331 b"new-shared"
1332 );
1333 let records = read_native_package_registry_at(&metadata_path)
1334 .unwrap()
1335 .into_records();
1336 assert_eq!(records.len(), 1);
1337 assert_eq!(records[0].version, "2.0.0");
1338 assert_eq!(records[0].artifacts, vec!["new.dll", "same.dll"]);
1339 assert_no_native_transaction_files(temp.path());
1340 assert_no_native_transaction_files(&destination);
1341 }
1342
1343 #[test]
1344 fn native_install_transaction_rolls_back_artifacts_and_registry() {
1345 let temp = tempfile::tempdir().unwrap();
1346 let destination = temp.path().join("extensions");
1347 let metadata_path = temp.path().join(NATIVE_PACKAGES_FILE);
1348 let build = temp.path().join("build");
1349 std::fs::create_dir_all(&destination).unwrap();
1350 std::fs::create_dir_all(&build).unwrap();
1351 std::fs::write(destination.join("old.dll"), b"old-only").unwrap();
1352 std::fs::write(destination.join("same.dll"), b"old-shared").unwrap();
1353 let old = InstalledNativePackage {
1354 name: "demo".into(),
1355 version: "1.0.0".into(),
1356 source: None,
1357 artifacts: vec!["old.dll".into(), "same.dll".into()],
1358 };
1359 write_native_packages_at(&metadata_path, &[old]).unwrap();
1360 let original_registry = std::fs::read(&metadata_path).unwrap();
1361 std::fs::write(build.join("new.dll"), b"new-only").unwrap();
1362 std::fs::write(build.join("same.dll"), b"new-shared").unwrap();
1363
1364 let error = install_native_package_at_with_hook(
1365 &destination,
1366 &metadata_path,
1367 InstalledNativePackage {
1368 name: "demo".into(),
1369 version: "2.0.0".into(),
1370 source: None,
1371 artifacts: Vec::new(),
1372 },
1373 &[build.join("new.dll"), build.join("same.dll")],
1374 true,
1375 |_, target| {
1376 if target == metadata_path {
1377 Err("injected registry activation failure".into())
1378 } else {
1379 Ok(())
1380 }
1381 },
1382 )
1383 .unwrap_err();
1384
1385 assert!(error.contains("injected registry activation failure"));
1386 assert_eq!(std::fs::read(&metadata_path).unwrap(), original_registry);
1387 assert_eq!(
1388 std::fs::read(destination.join("old.dll")).unwrap(),
1389 b"old-only"
1390 );
1391 assert_eq!(
1392 std::fs::read(destination.join("same.dll")).unwrap(),
1393 b"old-shared"
1394 );
1395 assert!(!destination.join("new.dll").exists());
1396 assert_no_native_transaction_files(temp.path());
1397 assert_no_native_transaction_files(&destination);
1398 }
1399
1400 fn assert_no_native_transaction_files(directory: &Path) {
1401 for entry in std::fs::read_dir(directory).unwrap() {
1402 let name = entry.unwrap().file_name().to_string_lossy().into_owned();
1403 assert!(!name.contains(".rpi-stage-"), "left staged file: {name}");
1404 assert!(!name.contains(".rpi-backup-"), "left backup file: {name}");
1405 }
1406 }
1407
1408 #[test]
1409 fn best_effort_registry_read_omits_corrupt_metadata() {
1410 let agent = TempAgent::new();
1411 std::fs::write(agent.metadata_path(), b"[{broken metadata").unwrap();
1412
1413 assert!(installed_native_packages().is_empty());
1414 }
1415
1416 #[test]
1417 fn install_fails_closed_before_work_when_registry_is_corrupt() {
1418 let agent = TempAgent::new();
1419 let path = agent.metadata_path();
1420 let original = b"[{broken metadata";
1421 std::fs::write(&path, original).unwrap();
1422
1423 assert_eq!(run(&args(&["demo"])), 1);
1424 assert_eq!(std::fs::read(&path).unwrap(), original);
1425 assert!(!agent.temp.path().join("extensions").exists());
1426 }
1427
1428 #[test]
1429 fn uninstall_fails_closed_without_removing_artifacts() {
1430 let agent = TempAgent::new();
1431 let path = agent.metadata_path();
1432 let original = b"[{broken metadata";
1433 std::fs::write(&path, original).unwrap();
1434 let extension_dir = agent.temp.path().join("extensions");
1435 std::fs::create_dir_all(&extension_dir).unwrap();
1436 let artifact = extension_dir.join("demo.dll");
1437 std::fs::write(&artifact, b"extension").unwrap();
1438
1439 assert_eq!(uninstall(&args(&["demo"])), 1);
1440 assert_eq!(std::fs::read(&path).unwrap(), original);
1441 assert_eq!(std::fs::read(artifact).unwrap(), b"extension");
1442 }
1443
1444 #[test]
1445 fn uninstall_commits_artifact_and_registry_changes_together() {
1446 let agent = TempAgent::new();
1447 let extension_dir = agent.temp.path().join("extensions");
1448 std::fs::create_dir_all(&extension_dir).unwrap();
1449 std::fs::write(extension_dir.join("demo.dll"), b"demo").unwrap();
1450 std::fs::write(extension_dir.join("other.dll"), b"other").unwrap();
1451 write_native_packages_at(
1452 &agent.metadata_path(),
1453 &[native_record("demo"), native_record("other")],
1454 )
1455 .unwrap();
1456
1457 assert_eq!(uninstall(&args(&["demo"])), 0);
1458 assert!(!extension_dir.join("demo.dll").exists());
1459 assert_eq!(
1460 std::fs::read(extension_dir.join("other.dll")).unwrap(),
1461 b"other"
1462 );
1463 assert_eq!(
1464 read_native_package_registry_at(&agent.metadata_path())
1465 .unwrap()
1466 .into_records(),
1467 vec![native_record("other")]
1468 );
1469 assert_no_native_transaction_files(agent.temp.path());
1470 assert_no_native_transaction_files(&extension_dir);
1471 }
1472
1473 #[test]
1474 fn uninstall_uses_recorded_artifacts_without_name_fallback() {
1475 let agent = TempAgent::new();
1476 let extension_dir = agent.temp.path().join("extensions");
1477 std::fs::create_dir_all(&extension_dir).unwrap();
1478 std::fs::write(extension_dir.join("recorded.dll"), b"managed").unwrap();
1479 std::fs::write(extension_dir.join("demo.dll"), b"unmanaged").unwrap();
1480 write_native_packages_at(
1481 &agent.metadata_path(),
1482 &[InstalledNativePackage {
1483 name: "demo".into(),
1484 version: "1.2.3".into(),
1485 source: None,
1486 artifacts: vec!["recorded.dll".into()],
1487 }],
1488 )
1489 .unwrap();
1490
1491 assert_eq!(uninstall(&args(&["demo"])), 0);
1492 assert!(!extension_dir.join("recorded.dll").exists());
1493 assert_eq!(
1494 std::fs::read(extension_dir.join("demo.dll")).unwrap(),
1495 b"unmanaged"
1496 );
1497 assert!(!agent.metadata_path().exists());
1498 assert_no_native_transaction_files(agent.temp.path());
1499 assert_no_native_transaction_files(&extension_dir);
1500 }
1501}