Skip to main content

rpi_cli/
install.rs

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