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