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