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` (the unified ABI; legacy `rpi_plugin_register_v2` and `rpi_plugin_register_v3` remain supported for migration)";
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 if path.is_some() && version.is_some() {
965 return Err("--path and --version cannot be used together".to_string());
966 }
967 let package = match package {
968 Some(package) => package,
969 None => {
970 let path = path
971 .as_deref()
972 .ok_or_else(|| "missing crate name (or use --path <directory>)".to_string())?;
973 package_name_from_manifest(path)?
974 }
975 };
976 if !valid_package_name(&package) {
977 return Err(format!("invalid Cargo package name `{package}`"));
978 }
979 Ok(InstallOptions {
980 package,
981 version,
982 path,
983 locked,
984 force,
985 })
986}
987
988fn value(args: &[String], index: usize, flag: &str) -> Result<String, String> {
989 args.get(index)
990 .filter(|value| !value.starts_with('-'))
991 .cloned()
992 .ok_or_else(|| format!("{flag} requires a value"))
993}
994
995fn package_name_from_manifest(path: &Path) -> Result<String, String> {
996 let manifest = if path.is_dir() {
997 path.join("Cargo.toml")
998 } else {
999 path.to_path_buf()
1000 };
1001 let output = Command::new("cargo")
1002 .args([
1003 "metadata",
1004 "--no-deps",
1005 "--format-version",
1006 "1",
1007 "--manifest-path",
1008 ])
1009 .arg(&manifest)
1010 .output()
1011 .map_err(|error| format!("could not run cargo metadata: {error}"))?;
1012 if !output.status.success() {
1013 return Err(format!(
1014 "could not inspect {}: {}",
1015 manifest.display(),
1016 String::from_utf8_lossy(&output.stderr).trim()
1017 ));
1018 }
1019 let metadata: serde_json::Value = serde_json::from_slice(&output.stdout)
1020 .map_err(|error| format!("cargo metadata returned invalid JSON: {error}"))?;
1021 let manifest = std::fs::canonicalize(&manifest)
1022 .map_err(|error| format!("could not resolve {}: {error}", manifest.display()))?;
1023 metadata
1024 .get("packages")
1025 .and_then(serde_json::Value::as_array)
1026 .and_then(|packages| {
1027 packages.iter().find_map(|package| {
1028 let package_manifest = package.get("manifest_path")?.as_str()?;
1029 let package_manifest = std::fs::canonicalize(package_manifest).ok()?;
1030 (package_manifest == manifest)
1031 .then(|| package.get("name")?.as_str().map(str::to_owned))
1032 .flatten()
1033 })
1034 })
1035 .filter(|name| valid_package_name(name))
1036 .ok_or_else(|| format!("{} has no valid [package].name", manifest.display()))
1037}
1038
1039fn valid_package_name(name: &str) -> bool {
1040 !name.is_empty()
1041 && name
1042 .bytes()
1043 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1044}
1045
1046fn help_requested() -> &'static str {
1047 "use `rpi install --help` for usage"
1048}
1049
1050pub fn print_help() {
1051 println!(
1052 "Usage: rpi install [<crate>] [options]\n\nInstall an rpi Rust cdylib extension from crates.io or a local Cargo crate.\n\nOptions:\n --version <version> Install a specific crates.io version\n --path <directory> Build a local extension crate; crate name may be omitted\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 --path . --force\n rpi install my-extension --path ../my-rpi-extension --force"
1053 );
1054}
1055
1056fn print_uninstall_help() {
1057 println!(
1058 "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"
1059 );
1060}
1061
1062fn write_manifest(path: &Path, options: &InstallOptions) -> Result<(), String> {
1063 let source_dir = path
1064 .parent()
1065 .ok_or_else(|| "temporary workspace has no parent directory".to_string())?
1066 .join("src");
1067 std::fs::create_dir_all(&source_dir).map_err(|error| error.to_string())?;
1068 std::fs::write(source_dir.join("lib.rs"), "pub fn installer_marker() {}\n")
1071 .map_err(|error| error.to_string())?;
1072 let dependency = if let Some(local_path) = &options.path {
1073 let absolute = if local_path.is_absolute() {
1074 local_path.clone()
1075 } else {
1076 std::env::current_dir()
1077 .map_err(|error| error.to_string())?
1078 .join(local_path)
1079 };
1080 format!(
1081 "rpi_extension_dep = {{ package = {:?}, path = {:?} }}",
1082 options.package,
1083 absolute.display().to_string()
1084 )
1085 } else {
1086 let version = options.version.as_deref().unwrap_or("*");
1087 format!(
1088 "rpi_extension_dep = {{ package = {:?}, version = {:?} }}",
1089 options.package, version
1090 )
1091 };
1092 let contents = format!(
1093 "[package]\nname = \"{INSTALLER_MANIFEST}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[workspace]\n\n[dependencies]\n{dependency}\n"
1094 );
1095 std::fs::write(path, contents).map_err(|error| error.to_string())
1096}
1097
1098fn cargo_command(
1099 subcommand: &str,
1100 manifest: &Path,
1101 options: &InstallOptions,
1102 build: bool,
1103) -> Result<(), String> {
1104 let mut command = Command::new("cargo");
1105 command.arg(subcommand).arg("--manifest-path").arg(manifest);
1106 if build {
1107 command
1108 .arg("--package")
1109 .arg(&options.package)
1110 .arg("--release")
1111 .arg("--target-dir")
1112 .arg(manifest.parent().unwrap().join("target"));
1113 }
1114 if options.locked {
1115 command.arg("--locked");
1116 }
1117 let status = command
1118 .stdin(Stdio::inherit())
1119 .stdout(Stdio::inherit())
1120 .stderr(Stdio::inherit())
1121 .status()
1122 .map_err(|error| format!("could not execute cargo: {error}"))?;
1123 if status.success() {
1124 Ok(())
1125 } else {
1126 Err(format!("cargo {subcommand} exited with {status}"))
1127 }
1128}
1129
1130fn cargo_metadata(manifest: &Path, options: &InstallOptions) -> Result<CargoMetadata, String> {
1131 let mut command = Command::new("cargo");
1132 command
1133 .arg("metadata")
1134 .arg("--format-version")
1135 .arg("1")
1136 .arg("--manifest-path")
1137 .arg(manifest);
1138 if options.locked {
1139 command.arg("--locked");
1140 }
1141 let output = command
1142 .output()
1143 .map_err(|error| format!("could not execute cargo: {error}"))?;
1144 if !output.status.success() {
1145 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
1146 }
1147 serde_json::from_slice(&output.stdout)
1148 .map_err(|error| format!("invalid cargo metadata: {error}"))
1149}
1150
1151fn find_artifacts(release_dir: &Path, targets: &[&CargoTarget]) -> Result<Vec<PathBuf>, String> {
1152 let mut artifacts = Vec::new();
1153 for target in targets {
1154 let wanted = normalize_name(&target.name);
1155 let mut matches = Vec::new();
1156 for dir in [release_dir.to_path_buf(), release_dir.join("deps")] {
1157 let entries = std::fs::read_dir(&dir).map_err(|error| {
1158 format!("could not inspect build output {}: {error}", dir.display())
1159 })?;
1160 for entry in entries.flatten() {
1161 let path = entry.path();
1162 if !is_dynamic_library(&path) {
1163 continue;
1164 }
1165 let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
1166 continue;
1167 };
1168 let normalized = normalize_name(stem.trim_start_matches("lib"));
1169 if normalized == wanted {
1170 matches.push(path);
1171 }
1172 }
1173 }
1174 matches.sort_by_key(|path| path.components().count());
1175 let artifact = matches.into_iter().next().ok_or_else(|| {
1176 format!(
1177 "Cargo built `{}` but no cdylib artifact was found in {}",
1178 target.name,
1179 release_dir.display()
1180 )
1181 })?;
1182 artifacts.push(artifact);
1183 }
1184 Ok(artifacts)
1185}
1186
1187fn normalize_name(name: &str) -> String {
1188 name.replace('-', "_").to_ascii_lowercase()
1189}
1190
1191fn is_dynamic_library(path: &Path) -> bool {
1192 matches!(
1193 path.extension()
1194 .and_then(|extension| extension.to_str())
1195 .map(|extension| extension.to_ascii_lowercase())
1196 .as_deref(),
1197 Some("dll" | "so" | "dylib" | "pyd")
1198 )
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203 use super::*;
1204
1205 fn args(values: &[&str]) -> Vec<String> {
1206 values.iter().map(|value| value.to_string()).collect()
1207 }
1208
1209 fn native_record(name: &str) -> InstalledNativePackage {
1210 InstalledNativePackage {
1211 name: name.to_string(),
1212 version: "1.2.3".to_string(),
1213 source: None,
1214 artifacts: vec![format!("{name}.dll")],
1215 }
1216 }
1217
1218 struct TempAgent {
1219 _guard: std::sync::MutexGuard<'static, ()>,
1220 temp: tempfile::TempDir,
1221 previous: Option<std::ffi::OsString>,
1222 }
1223
1224 impl TempAgent {
1225 fn new() -> Self {
1226 let guard = crate::config::test_support::env_lock()
1227 .lock()
1228 .unwrap_or_else(|poisoned| poisoned.into_inner());
1229 let previous = std::env::var_os(crate::config::CONFIG_DIR_ENV);
1230 let temp = tempfile::tempdir().unwrap();
1231 std::env::set_var(crate::config::CONFIG_DIR_ENV, temp.path());
1232 Self {
1233 _guard: guard,
1234 temp,
1235 previous,
1236 }
1237 }
1238
1239 fn metadata_path(&self) -> PathBuf {
1240 self.temp.path().join(NATIVE_PACKAGES_FILE)
1241 }
1242 }
1243
1244 impl Drop for TempAgent {
1245 fn drop(&mut self) {
1246 match self.previous.take() {
1247 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
1248 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
1249 }
1250 }
1251 }
1252
1253 #[test]
1254 fn parses_registry_package_and_options() {
1255 let parsed = parse_args(&args(&["my-extension", "--version", "1.2.3", "--force"])).unwrap();
1256 assert_eq!(parsed.package, "my-extension");
1257 assert_eq!(parsed.version.as_deref(), Some("1.2.3"));
1258 assert!(parsed.force);
1259 }
1260
1261 #[test]
1262 fn parses_local_package() {
1263 let parsed = parse_args(&args(&["--path", "../extension", "my-extension"])).unwrap();
1264 assert_eq!(parsed.path, Some(PathBuf::from("../extension")));
1265 }
1266
1267 #[test]
1268 fn local_path_can_infer_package_name() {
1269 let parsed = parse_args(&args(&["--path", ".", "--force"])).unwrap();
1270 assert!(!parsed.package.is_empty());
1271 assert!(valid_package_name(&parsed.package));
1272 assert!(parsed.force);
1273 }
1274
1275 #[test]
1276 fn rejects_non_extension_options_and_invalid_names() {
1277 assert!(parse_args(&args(&["my.extension"])).is_err());
1278 assert!(parse_args(&args(&["my-extension", "--unknown"])).is_err());
1279 assert!(parse_args(&args(&["my-extension", "--path", ".", "--version", "1"])).is_err());
1280 }
1281
1282 #[test]
1283 fn cdylib_hint_recommends_unified_and_documents_legacy_compatibility() {
1284 assert!(CDYLIB_EXTENSION_HINT.contains("rpi_plugin_register"));
1285 assert!(CDYLIB_EXTENSION_HINT.contains("rpi_plugin_register_v2"));
1286 assert!(CDYLIB_EXTENSION_HINT.contains("rpi_plugin_register_v3"));
1287 }
1288
1289 #[test]
1290 fn registry_read_distinguishes_missing_and_valid_files() {
1291 let temp = tempfile::tempdir().unwrap();
1292 let path = temp.path().join(NATIVE_PACKAGES_FILE);
1293
1294 assert_eq!(
1295 read_native_package_registry_at(&path).unwrap(),
1296 NativePackageRegistry::Missing
1297 );
1298
1299 std::fs::write(
1300 &path,
1301 br#"[{"name":"demo","version":"1.2.3","source":null}]"#,
1302 )
1303 .unwrap();
1304 assert_eq!(
1305 read_native_package_registry_at(&path).unwrap(),
1306 NativePackageRegistry::Valid(vec![InstalledNativePackage {
1307 name: "demo".to_string(),
1308 version: "1.2.3".to_string(),
1309 source: None,
1310 artifacts: Vec::new(),
1311 }])
1312 );
1313 }
1314
1315 #[test]
1316 fn registry_read_rejects_corrupt_json() {
1317 let temp = tempfile::tempdir().unwrap();
1318 let path = temp.path().join(NATIVE_PACKAGES_FILE);
1319 std::fs::write(&path, b"[{").unwrap();
1320
1321 let error = read_native_package_registry_at(&path).unwrap_err();
1322 assert!(error.contains("invalid JSON or schema"));
1323 }
1324
1325 #[test]
1326 fn registry_read_rejects_wrong_schema() {
1327 let temp = tempfile::tempdir().unwrap();
1328 let path = temp.path().join(NATIVE_PACKAGES_FILE);
1329 std::fs::write(&path, br#"{"name":"demo","version":"1.2.3"}"#).unwrap();
1330
1331 let error = read_native_package_registry_at(&path).unwrap_err();
1332 assert!(error.contains("invalid JSON or schema"));
1333 }
1334
1335 #[test]
1336 fn record_does_not_replace_a_corrupt_registry() {
1337 let temp = tempfile::tempdir().unwrap();
1338 let path = temp.path().join(NATIVE_PACKAGES_FILE);
1339 let original = b"[{broken metadata";
1340 std::fs::write(&path, original).unwrap();
1341
1342 assert!(record_native_package_at(&path, &native_record("demo")).is_err());
1343 assert_eq!(std::fs::read(&path).unwrap(), original);
1344 }
1345
1346 #[test]
1347 fn native_install_transaction_replaces_artifacts_and_removes_stale_ones() {
1348 let temp = tempfile::tempdir().unwrap();
1349 let destination = temp.path().join("extensions");
1350 let metadata_path = temp.path().join(NATIVE_PACKAGES_FILE);
1351 let build = temp.path().join("build");
1352 std::fs::create_dir_all(&destination).unwrap();
1353 std::fs::create_dir_all(&build).unwrap();
1354 std::fs::write(destination.join("old.dll"), b"old-only").unwrap();
1355 std::fs::write(destination.join("same.dll"), b"old-shared").unwrap();
1356 let old = InstalledNativePackage {
1357 name: "demo".into(),
1358 version: "1.0.0".into(),
1359 source: None,
1360 artifacts: vec!["old.dll".into(), "same.dll".into()],
1361 };
1362 write_native_packages_at(&metadata_path, &[old]).unwrap();
1363 std::fs::write(build.join("new.dll"), b"new-only").unwrap();
1364 std::fs::write(build.join("same.dll"), b"new-shared").unwrap();
1365
1366 let installed = install_native_package_at(
1367 &destination,
1368 &metadata_path,
1369 InstalledNativePackage {
1370 name: "demo".into(),
1371 version: "2.0.0".into(),
1372 source: None,
1373 artifacts: Vec::new(),
1374 },
1375 &[build.join("new.dll"), build.join("same.dll")],
1376 true,
1377 )
1378 .unwrap();
1379
1380 assert_eq!(
1381 installed,
1382 vec![destination.join("new.dll"), destination.join("same.dll")]
1383 );
1384 assert!(!destination.join("old.dll").exists());
1385 assert_eq!(
1386 std::fs::read(destination.join("new.dll")).unwrap(),
1387 b"new-only"
1388 );
1389 assert_eq!(
1390 std::fs::read(destination.join("same.dll")).unwrap(),
1391 b"new-shared"
1392 );
1393 let records = read_native_package_registry_at(&metadata_path)
1394 .unwrap()
1395 .into_records();
1396 assert_eq!(records.len(), 1);
1397 assert_eq!(records[0].version, "2.0.0");
1398 assert_eq!(records[0].artifacts, vec!["new.dll", "same.dll"]);
1399 assert_no_native_transaction_files(temp.path());
1400 assert_no_native_transaction_files(&destination);
1401 }
1402
1403 #[test]
1404 fn native_install_transaction_rolls_back_artifacts_and_registry() {
1405 let temp = tempfile::tempdir().unwrap();
1406 let destination = temp.path().join("extensions");
1407 let metadata_path = temp.path().join(NATIVE_PACKAGES_FILE);
1408 let build = temp.path().join("build");
1409 std::fs::create_dir_all(&destination).unwrap();
1410 std::fs::create_dir_all(&build).unwrap();
1411 std::fs::write(destination.join("old.dll"), b"old-only").unwrap();
1412 std::fs::write(destination.join("same.dll"), b"old-shared").unwrap();
1413 let old = InstalledNativePackage {
1414 name: "demo".into(),
1415 version: "1.0.0".into(),
1416 source: None,
1417 artifacts: vec!["old.dll".into(), "same.dll".into()],
1418 };
1419 write_native_packages_at(&metadata_path, &[old]).unwrap();
1420 let original_registry = std::fs::read(&metadata_path).unwrap();
1421 std::fs::write(build.join("new.dll"), b"new-only").unwrap();
1422 std::fs::write(build.join("same.dll"), b"new-shared").unwrap();
1423
1424 let error = install_native_package_at_with_hook(
1425 &destination,
1426 &metadata_path,
1427 InstalledNativePackage {
1428 name: "demo".into(),
1429 version: "2.0.0".into(),
1430 source: None,
1431 artifacts: Vec::new(),
1432 },
1433 &[build.join("new.dll"), build.join("same.dll")],
1434 true,
1435 |_, target| {
1436 if target == metadata_path {
1437 Err("injected registry activation failure".into())
1438 } else {
1439 Ok(())
1440 }
1441 },
1442 )
1443 .unwrap_err();
1444
1445 assert!(error.contains("injected registry activation failure"));
1446 assert_eq!(std::fs::read(&metadata_path).unwrap(), original_registry);
1447 assert_eq!(
1448 std::fs::read(destination.join("old.dll")).unwrap(),
1449 b"old-only"
1450 );
1451 assert_eq!(
1452 std::fs::read(destination.join("same.dll")).unwrap(),
1453 b"old-shared"
1454 );
1455 assert!(!destination.join("new.dll").exists());
1456 assert_no_native_transaction_files(temp.path());
1457 assert_no_native_transaction_files(&destination);
1458 }
1459
1460 fn assert_no_native_transaction_files(directory: &Path) {
1461 for entry in std::fs::read_dir(directory).unwrap() {
1462 let name = entry.unwrap().file_name().to_string_lossy().into_owned();
1463 assert!(!name.contains(".rpi-stage-"), "left staged file: {name}");
1464 assert!(!name.contains(".rpi-backup-"), "left backup file: {name}");
1465 }
1466 }
1467
1468 #[test]
1469 fn best_effort_registry_read_omits_corrupt_metadata() {
1470 let agent = TempAgent::new();
1471 std::fs::write(agent.metadata_path(), b"[{broken metadata").unwrap();
1472
1473 assert!(installed_native_packages().is_empty());
1474 }
1475
1476 #[test]
1477 fn install_fails_closed_before_work_when_registry_is_corrupt() {
1478 let agent = TempAgent::new();
1479 let path = agent.metadata_path();
1480 let original = b"[{broken metadata";
1481 std::fs::write(&path, original).unwrap();
1482
1483 assert_eq!(run(&args(&["demo"])), 1);
1484 assert_eq!(std::fs::read(&path).unwrap(), original);
1485 assert!(!agent.temp.path().join("extensions").exists());
1486 }
1487
1488 #[test]
1489 fn uninstall_fails_closed_without_removing_artifacts() {
1490 let agent = TempAgent::new();
1491 let path = agent.metadata_path();
1492 let original = b"[{broken metadata";
1493 std::fs::write(&path, original).unwrap();
1494 let extension_dir = agent.temp.path().join("extensions");
1495 std::fs::create_dir_all(&extension_dir).unwrap();
1496 let artifact = extension_dir.join("demo.dll");
1497 std::fs::write(&artifact, b"extension").unwrap();
1498
1499 assert_eq!(uninstall(&args(&["demo"])), 1);
1500 assert_eq!(std::fs::read(&path).unwrap(), original);
1501 assert_eq!(std::fs::read(artifact).unwrap(), b"extension");
1502 }
1503
1504 #[test]
1505 fn uninstall_commits_artifact_and_registry_changes_together() {
1506 let agent = TempAgent::new();
1507 let extension_dir = agent.temp.path().join("extensions");
1508 std::fs::create_dir_all(&extension_dir).unwrap();
1509 std::fs::write(extension_dir.join("demo.dll"), b"demo").unwrap();
1510 std::fs::write(extension_dir.join("other.dll"), b"other").unwrap();
1511 write_native_packages_at(
1512 &agent.metadata_path(),
1513 &[native_record("demo"), native_record("other")],
1514 )
1515 .unwrap();
1516
1517 assert_eq!(uninstall(&args(&["demo"])), 0);
1518 assert!(!extension_dir.join("demo.dll").exists());
1519 assert_eq!(
1520 std::fs::read(extension_dir.join("other.dll")).unwrap(),
1521 b"other"
1522 );
1523 assert_eq!(
1524 read_native_package_registry_at(&agent.metadata_path())
1525 .unwrap()
1526 .into_records(),
1527 vec![native_record("other")]
1528 );
1529 assert_no_native_transaction_files(agent.temp.path());
1530 assert_no_native_transaction_files(&extension_dir);
1531 }
1532
1533 #[test]
1534 fn uninstall_uses_recorded_artifacts_without_name_fallback() {
1535 let agent = TempAgent::new();
1536 let extension_dir = agent.temp.path().join("extensions");
1537 std::fs::create_dir_all(&extension_dir).unwrap();
1538 std::fs::write(extension_dir.join("recorded.dll"), b"managed").unwrap();
1539 std::fs::write(extension_dir.join("demo.dll"), b"unmanaged").unwrap();
1540 write_native_packages_at(
1541 &agent.metadata_path(),
1542 &[InstalledNativePackage {
1543 name: "demo".into(),
1544 version: "1.2.3".into(),
1545 source: None,
1546 artifacts: vec!["recorded.dll".into()],
1547 }],
1548 )
1549 .unwrap();
1550
1551 assert_eq!(uninstall(&args(&["demo"])), 0);
1552 assert!(!extension_dir.join("recorded.dll").exists());
1553 assert_eq!(
1554 std::fs::read(extension_dir.join("demo.dll")).unwrap(),
1555 b"unmanaged"
1556 );
1557 assert!(!agent.metadata_path().exists());
1558 assert_no_native_transaction_files(agent.temp.path());
1559 assert_no_native_transaction_files(&extension_dir);
1560 }
1561}