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