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